From af1688284766f2dfabd783b3bd6e88a9227d966b Mon Sep 17 00:00:00 2001 From: Dominika Zemanovicova Date: Thu, 23 Jul 2026 12:56:44 +0200 Subject: [PATCH 01/18] Add database to dora Assisted-By: Claude Signed-off-by: Dominika Zemanovicova --- .../scorecard-backend-module-dora/knexfile.js | 56 +++++++++++++++++++ .../migrations/20260723000000_init.js | 52 +++++++++++++++++ .../package.json | 4 +- .../src/database/migration.ts | 37 ++++++++++++ .../src/module.ts | 6 +- workspaces/scorecard/yarn.lock | 1 + 6 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-dora/knexfile.js create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-dora/migrations/20260723000000_init.js create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/migration.ts diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/knexfile.js b/workspaces/scorecard/plugins/scorecard-backend-module-dora/knexfile.js new file mode 100644 index 00000000000..37f31191c5e --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/knexfile.js @@ -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', + }, + }, +}; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/migrations/20260723000000_init.js b/workspaces/scorecard/plugins/scorecard-backend-module-dora/migrations/20260723000000_init.js new file mode 100644 index 00000000000..10fd53efe92 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/migrations/20260723000000_init.js @@ -0,0 +1,52 @@ +/* + * 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('original_deployment_id').notNullable(); + table.string('commit_sha').notNullable(); + table.string('environment').nullable(); + table.dateTime('created_at', { precision: 0 }).notNullable(); + table.string('result').notNullable(); + }); + + await knex.schema.createTable('dora_incidents', table => { + table.string('id').primary().notNullable(); + table.string('original_incident_id').notNullable(); + table.dateTime('created_at', { precision: 0 }).notNullable(); + table.dateTime('updated_at', { precision: 0 }).notNullable(); + table.dateTime('resolution_at', { precision: 0 }).nullable(); + }); + + await knex.schema.createTable('dora_pull_requests', table => { + table.string('id').primary().notNullable(); + table.string('original_pr_id').notNullable(); + table.dateTime('first_commit_at', { precision: 0 }).notNullable(); + table + .string('deployment_id') + .references('id') + .inTable('dora_deployments') + .onDelete('CASCADE') + .nullable(); + }); +}; + +exports.down = async function down(knex) { + await knex.schema.dropTable('dora_pull_requests'); + await knex.schema.dropTable('dora_incidents'); + await knex.schema.dropTable('dora_deployments'); +}; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/package.json b/workspaces/scorecard/plugins/scorecard-backend-module-dora/package.json index 76255a4b41f..77ceb7c1da6 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/package.json +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/package.json @@ -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": { @@ -54,7 +55,8 @@ }, "files": [ "config.d.ts", - "dist" + "dist", + "migrations" ], "repository": { "type": "git", diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/migration.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/migration.ts new file mode 100644 index 00000000000..aee470ed73d --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/migration.ts @@ -0,0 +1,37 @@ +/* + * 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. + */ +import { + DatabaseService, + resolvePackagePath, +} from '@backstage/backend-plugin-api'; + +const migrationsDir = resolvePackagePath( + '@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-dora', + 'migrations', +); + +export async function migrate(databaseManager: DatabaseService) { + const knex = await databaseManager.getClient(); + + if (!databaseManager.migrations?.skip) { + await knex.migrate.latest({ + directory: migrationsDir, + // Modules share the parent plugin DB; use a dedicated history table so + // DORA migrations do not collide with scorecard-backend's knex_migrations. + tableName: 'dora_knex_migrations', + }); + } +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts index 9b81ad021ad..82679956db5 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts @@ -21,6 +21,7 @@ import { scorecardCollectorsServiceRef, scorecardMetricsExtensionPoint, } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { migrate } from './database/migration'; import { DoraChangeFailureRateProvider } from './metricProviders/DoraChangeFailureRateProvider'; import { DoraDeploymentFrequencyProvider } from './metricProviders/DoraDeploymentFrequencyProvider'; import { DoraMedianLeadTimeForChangesProvider } from './metricProviders/DoraMedianLeadTimeForChangesProvider'; @@ -34,10 +35,13 @@ export const scorecardModuleDora = createBackendModule({ deps: { collectorsService: scorecardCollectorsServiceRef, config: coreServices.rootConfig, + database: coreServices.database, logger: coreServices.logger, metrics: scorecardMetricsExtensionPoint, }, - async init({ collectorsService, config, logger, metrics }) { + async init({ collectorsService, config, database, logger, metrics }) { + await migrate(database); + metrics.addMetricProvider( DoraDeploymentFrequencyProvider.fromConfig(config, { collectorsService, diff --git a/workspaces/scorecard/yarn.lock b/workspaces/scorecard/yarn.lock index 7c33969baf0..7cf76390357 100644 --- a/workspaces/scorecard/yarn.lock +++ b/workspaces/scorecard/yarn.lock @@ -9692,6 +9692,7 @@ __metadata: "@backstage/types": "npm:^1.2.2" "@red-hat-developer-hub/backstage-plugin-scorecard-common": "workspace:^" "@red-hat-developer-hub/backstage-plugin-scorecard-node": "workspace:^" + knex: "npm:^3.1.0" zod: "npm:^3.22.4" languageName: unknown linkType: soft From dd2b8b95d85e0a31060bed8b66526d40d50ab345 Mon Sep 17 00:00:00 2001 From: Dominika Zemanovicova Date: Sun, 2 Aug 2026 19:44:12 +0200 Subject: [PATCH 02/18] Add updated to jira collector Assisted-By: Cursor Desktop Signed-off-by: Dominika Zemanovicova --- .../src/clients/mappers.test.ts | 4 ++++ .../src/clients/mappers.ts | 1 + .../src/clients/schemas/jiraSearchIssue.ts | 1 + .../src/clients/types.ts | 1 + .../src/collectors/JiraIncidentsCollector.test.ts | 10 ++++++---- .../src/collectors/JiraIncidentsCollector.ts | 1 + .../src/collectors/incidentJql.test.ts | 11 ++++++----- .../src/collectors/incidentJql.ts | 3 +++ .../src/collectors/schemas/incidentSchemas.ts | 2 ++ .../src/strategies/JiraCloudClient.test.ts | 8 +++++++- .../src/strategies/JiraCloudClientStrategy.ts | 2 +- .../strategies/JiraDataCenterClientStrategy.test.ts | 8 +++++++- .../src/strategies/JiraDataCenterClientStrategy.ts | 2 +- 13 files changed, 41 insertions(+), 13 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/mappers.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/mappers.test.ts index f8b30bf9ea1..7f1159b8433 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/mappers.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/mappers.test.ts @@ -23,6 +23,7 @@ describe('mapJiraIssues', () => { id: '10001', fields: { created: '2026-06-01T10:00:00.000+0530', + updated: '2026-06-01T12:00:00.000+0530', resolutiondate: '2026-06-01T12:00:00.000+0530', }, }, @@ -30,6 +31,7 @@ describe('mapJiraIssues', () => { id: '10002', fields: { created: '2026-06-02T10:00:00.000Z', + updated: '2026-06-02T10:00:00.000Z', resolutiondate: null, }, }, @@ -40,11 +42,13 @@ describe('mapJiraIssues', () => { { id: '10001', createdAt: '2026-06-01T04:30:00.000Z', + updatedAt: '2026-06-01T06:30:00.000Z', resolutionAt: '2026-06-01T06:30:00.000Z', }, { id: '10002', createdAt: '2026-06-02T10:00:00.000Z', + updatedAt: '2026-06-02T10:00:00.000Z', resolutionAt: null, }, ]); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/mappers.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/mappers.ts index 370bf96ebe9..153cb18b202 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/mappers.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/mappers.ts @@ -22,6 +22,7 @@ export function mapJiraIssues(issues: JiraSearchIssue[]): JiraIssue[] { return issues.map(issue => ({ id: issue.id, createdAt: toIsoDateTime(issue.fields.created), + updatedAt: toIsoDateTime(issue.fields.updated), resolutionAt: issue.fields.resolutiondate ? toIsoDateTime(issue.fields.resolutiondate) : null, diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/schemas/jiraSearchIssue.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/schemas/jiraSearchIssue.ts index 5d50e6cec58..43ae3cae32e 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/schemas/jiraSearchIssue.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/schemas/jiraSearchIssue.ts @@ -22,6 +22,7 @@ export const jiraSearchIssueSchema = z fields: z .object({ created: z.string(), + updated: z.string(), resolutiondate: z.string().nullable().optional(), }) .passthrough(), diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/types.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/types.ts index cd800722552..a98c33aeadb 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/types.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/types.ts @@ -19,6 +19,7 @@ export type Product = 'datacenter' | 'cloud'; export interface JiraIssue { id: string; createdAt: string; + updatedAt: string; resolutionAt: string | null; } diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/collectors/JiraIncidentsCollector.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/collectors/JiraIncidentsCollector.test.ts index 503ad4b7391..d0f6a16d6bf 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/collectors/JiraIncidentsCollector.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/collectors/JiraIncidentsCollector.test.ts @@ -40,6 +40,7 @@ describe('JiraIncidentsCollector', () => { const input = { from: '2026-06-01T00:00:00.000Z', to: '2026-06-30T23:59:59.999Z', + updatedSince: '2026-06-01T00:00:00.000Z', }; const mockEntity = newEntityComponent({ @@ -50,6 +51,7 @@ describe('JiraIncidentsCollector', () => { { id: 'INC-100', createdAt: '2026-06-01T10:00:00.000Z', + updatedAt: '2026-06-01T12:00:00.000Z', resolutionAt: '2026-06-01T12:00:00.000Z', }, ]; @@ -79,7 +81,7 @@ describe('JiraIncidentsCollector', () => { await collector.collect({ entity: mockEntity, input }); expect(mockJiraClient.getIssues).toHaveBeenCalledWith( - '(project = "INC") AND (type = "Incident") AND (created >= "2026-06-01 00:00") AND (created <= "2026-06-30 23:59")', + '(project = "INC") AND (type = "Incident") AND (created >= "2026-06-01 00:00") AND (created <= "2026-06-30 23:59") AND (updated >= "2026-06-01 00:00")', ); }); @@ -90,7 +92,7 @@ describe('JiraIncidentsCollector', () => { }); expect(mockJiraClient.getIssues).toHaveBeenCalledWith( - '(project = "INC") AND (type = "ServiceIncident") AND (created >= "2026-06-01 00:00") AND (created <= "2026-06-30 23:59")', + '(project = "INC") AND (type = "ServiceIncident") AND (created >= "2026-06-01 00:00") AND (created <= "2026-06-30 23:59") AND (updated >= "2026-06-01 00:00")', ); }); @@ -105,7 +107,7 @@ describe('JiraIncidentsCollector', () => { }); expect(mockJiraClient.getIssues).toHaveBeenCalledWith( - '(project = "INC") AND (component = "Payments") AND (labels = "sev-1") AND (type = "Incident") AND (created >= "2026-06-01 00:00") AND (created <= "2026-06-30 23:59")', + '(project = "INC") AND (component = "Payments") AND (labels = "sev-1") AND (type = "Incident") AND (created >= "2026-06-01 00:00") AND (created <= "2026-06-30 23:59") AND (updated >= "2026-06-01 00:00")', ); }); @@ -119,7 +121,7 @@ describe('JiraIncidentsCollector', () => { }); expect(mockJiraClient.getIssues).toHaveBeenCalledWith( - '(project = "INC") AND (component = "Payments") AND (type = "ServiceIncident") AND (created >= "2026-06-01 00:00") AND (created <= "2026-06-30 23:59")', + '(project = "INC") AND (component = "Payments") AND (type = "ServiceIncident") AND (created >= "2026-06-01 00:00") AND (created <= "2026-06-30 23:59") AND (updated >= "2026-06-01 00:00")', ); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/collectors/JiraIncidentsCollector.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/collectors/JiraIncidentsCollector.ts index 8c3b335bd8e..df4b643e422 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/collectors/JiraIncidentsCollector.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/collectors/JiraIncidentsCollector.ts @@ -78,6 +78,7 @@ export class JiraIncidentsCollector from: options.input.from, to: options.input.to, issueType: options.input.issueType, + updatedSince: options.input.updatedSince, }, options.entity, ); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/collectors/incidentJql.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/collectors/incidentJql.test.ts index 5d897a9f551..ff44a54f55f 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/collectors/incidentJql.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/collectors/incidentJql.test.ts @@ -24,6 +24,7 @@ describe('buildIncidentJql', () => { const options = { from: '2026-06-01T00:00:00.000Z', to: '2026-06-30T23:59:59.999Z', + updatedSince: '2026-05-01T00:00:00.000Z', }; const baseFilters = { @@ -34,7 +35,7 @@ describe('buildIncidentJql', () => { const jql = buildIncidentJql(baseFilters, options, newEntityComponent()); expect(jql).toBe( - '(project = "INC") AND (type = "Incident") AND (created >= "2026-06-01 00:00") AND (created <= "2026-06-30 23:59")', + '(project = "INC") AND (type = "Incident") AND (created >= "2026-06-01 00:00") AND (created <= "2026-06-30 23:59") AND (updated >= "2026-05-01 00:00")', ); }); @@ -46,7 +47,7 @@ describe('buildIncidentJql', () => { ); expect(jql).toBe( - '(project = "INC") AND (type = "ServiceIncident") AND (created >= "2026-06-01 00:00") AND (created <= "2026-06-30 23:59")', + '(project = "INC") AND (type = "ServiceIncident") AND (created >= "2026-06-01 00:00") AND (created <= "2026-06-30 23:59") AND (updated >= "2026-05-01 00:00")', ); }); @@ -62,7 +63,7 @@ describe('buildIncidentJql', () => { ); expect(jql).toBe( - '(project = "INC") AND (component = "Payments") AND (labels = "sev-1") AND (type = "Incident") AND (created >= "2026-06-01 00:00") AND (created <= "2026-06-30 23:59")', + '(project = "INC") AND (component = "Payments") AND (labels = "sev-1") AND (type = "Incident") AND (created >= "2026-06-01 00:00") AND (created <= "2026-06-30 23:59") AND (updated >= "2026-05-01 00:00")', ); }); @@ -76,7 +77,7 @@ describe('buildIncidentJql', () => { ); expect(jql).toBe( - '(project = "INC") AND (type = "ProductionIncident") AND (created >= "2026-06-01 00:00") AND (created <= "2026-06-30 23:59")', + '(project = "INC") AND (type = "ProductionIncident") AND (created >= "2026-06-01 00:00") AND (created <= "2026-06-30 23:59") AND (updated >= "2026-05-01 00:00")', ); expect(jql).not.toContain('(type = "ServiceIncident")'); }); @@ -91,7 +92,7 @@ describe('buildIncidentJql', () => { ); expect(jql).toBe( - '(project = "INC") AND (type = "ProductionIncident") AND (created >= "2026-06-01 00:00") AND (created <= "2026-06-30 23:59")', + '(project = "INC") AND (type = "ProductionIncident") AND (created >= "2026-06-01 00:00") AND (created <= "2026-06-30 23:59") AND (updated >= "2026-05-01 00:00")', ); }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/collectors/incidentJql.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/collectors/incidentJql.ts index 53519d14973..0caa0098763 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/collectors/incidentJql.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/collectors/incidentJql.ts @@ -34,11 +34,13 @@ export function buildIncidentJql( from: string; to: string; issueType?: string; + updatedSince: string; }, entity: Entity, ): string { const from = toJiraDateTime(options.from); const to = toJiraDateTime(options.to); + const updatedSince = toJiraDateTime(options.updatedSince); const issueType = resolveIncidentIssueType(entity, options.issueType); return joinJqlClauses([ @@ -46,6 +48,7 @@ export function buildIncidentJql( `type = "${issueType}"`, `created >= "${from}"`, `created <= "${to}"`, + `updated >= "${updatedSince}"`, ]); } diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/collectors/schemas/incidentSchemas.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/collectors/schemas/incidentSchemas.ts index 5aad56ea275..1cf7d4190f2 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/collectors/schemas/incidentSchemas.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/collectors/schemas/incidentSchemas.ts @@ -26,12 +26,14 @@ export const incidentsCollectorInputSchema = z * `jira/incident-issue-type` when set. */ issueType: z.string().min(1).optional(), + updatedSince: z.string().datetime(), }) .passthrough(); const incidentSchema = z.object({ id: z.string(), createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), resolutionAt: z.string().datetime().nullable(), }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/strategies/JiraCloudClient.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/strategies/JiraCloudClient.test.ts index b94361cc58c..337902e6b61 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/strategies/JiraCloudClient.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/strategies/JiraCloudClient.test.ts @@ -314,6 +314,7 @@ describe('JiraCloudClient', () => { id: '10001', fields: { created: '2026-06-01T10:00:00.000+0530', + updated: '2026-06-01T12:00:00.000+0530', resolutiondate: '2026-06-01T12:00:00.000+0530', }, }, @@ -335,11 +336,16 @@ describe('JiraCloudClient', () => { { id: '10001', createdAt: '2026-06-01T04:30:00.000Z', + updatedAt: '2026-06-01T06:30:00.000Z', resolutionAt: '2026-06-01T06:30:00.000Z', }, ]); expect(requestBody.jql).toContain('project = "INC"'); - expect(requestBody.fields).toEqual(['created', 'resolutiondate']); + expect(requestBody.fields).toEqual([ + 'created', + 'updated', + 'resolutiondate', + ]); expect(requestBody).not.toHaveProperty('maxResults'); }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/strategies/JiraCloudClientStrategy.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/strategies/JiraCloudClientStrategy.ts index dace0e187ec..079f5cc025e 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/strategies/JiraCloudClientStrategy.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/strategies/JiraCloudClientStrategy.ts @@ -132,7 +132,7 @@ export class JiraCloudClientStrategy extends JiraClient { method: 'POST', body: { jql, - fields: ['created', 'resolutiondate'], + fields: ['created', 'updated', 'resolutiondate'], }, responseSchema: z.object({ issues: z.array(jiraSearchIssueSchema), diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/strategies/JiraDataCenterClientStrategy.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/strategies/JiraDataCenterClientStrategy.test.ts index 5a5c8bf007b..106dfe9cddd 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/strategies/JiraDataCenterClientStrategy.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/strategies/JiraDataCenterClientStrategy.test.ts @@ -333,6 +333,7 @@ describe('JiraDataCenterClient', () => { id: '10001', fields: { created: '2026-06-01T10:00:00.000+0530', + updated: '2026-06-01T12:00:00.000+0530', resolutiondate: '2026-06-01T12:00:00.000+0530', }, }, @@ -353,11 +354,16 @@ describe('JiraDataCenterClient', () => { { id: '10001', createdAt: '2026-06-01T04:30:00.000Z', + updatedAt: '2026-06-01T06:30:00.000Z', resolutionAt: '2026-06-01T06:30:00.000Z', }, ]); expect(requestBody.jql).toContain('project = "INC"'); - expect(requestBody.fields).toEqual(['created', 'resolutiondate']); + expect(requestBody.fields).toEqual([ + 'created', + 'updated', + 'resolutiondate', + ]); expect(requestBody).not.toHaveProperty('maxResults'); expect(requestBody.startAt).toBe(0); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/strategies/JiraDataCenterClientStrategy.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/strategies/JiraDataCenterClientStrategy.ts index bf8fd01557c..99ee9e9cd5d 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/strategies/JiraDataCenterClientStrategy.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/strategies/JiraDataCenterClientStrategy.ts @@ -130,7 +130,7 @@ export class JiraDataCenterClientStrategy extends JiraClient { method: 'POST', body: { jql, - fields: ['created', 'resolutiondate'], + fields: ['created', 'updated', 'resolutiondate'], }, responseSchema: z.object({ issues: z.array(jiraSearchIssueSchema), From eb2470ebcf47c41aa5b25f43ce8ff2bd6e6339b5 Mon Sep 17 00:00:00 2001 From: Dominika Zemanovicova Date: Sun, 2 Aug 2026 22:20:22 +0200 Subject: [PATCH 03/18] Sync and read from database Assisted-By: Cursor Desktop Signed-off-by: Dominika Zemanovicova --- .../migrations/20260723000000_init.js | 40 ++ .../database/DatabaseDoraDeployments.test.ts | 114 +++++ .../src/database/DatabaseDoraDeployments.ts | 99 ++++ .../database/DatabaseDoraIncidents.test.ts | 91 ++++ .../src/database/DatabaseDoraIncidents.ts | 99 ++++ .../database/DatabaseDoraPullRequests.test.ts | 146 ++++++ .../src/database/DatabaseDoraPullRequests.ts | 75 +++ .../src/database/__fixtures__/index.ts | 17 + .../src/database/__fixtures__/testDatabase.ts | 40 ++ .../src/database/mappers.test.ts | 225 +++++++++ .../src/database/mappers.ts | 141 ++++++ .../src/database/types.ts | 72 +++ .../DoraChangeFailureRateProvider.test.ts | 448 +++++++---------- .../DoraChangeFailureRateProvider.ts | 122 ++--- .../DoraDeploymentFrequencyProvider.test.ts | 207 ++++---- .../DoraDeploymentFrequencyProvider.ts | 67 ++- .../DoraMeanTimeToRestoreProvider.test.ts | 203 ++++---- .../DoraMeanTimeToRestoreProvider.ts | 70 ++- ...raMedianLeadTimeForChangesProvider.test.ts | 464 +++++++++--------- .../DoraMedianLeadTimeForChangesProvider.ts | 109 ++-- .../src/metricProviders/__fixtures__/index.ts | 1 + .../__fixtures__/mockDoraDataService.ts | 113 +++++ .../schemas/deploymentSchemas.test.ts | 72 +++ .../schemas/incidentSchemas.ts | 2 + .../utils/deploymentFilterUtils.test.ts | 8 +- .../utils/deploymentFilterUtils.ts | 6 +- .../src/module.ts | 37 +- .../src/service/DoraDataService.test.ts | 187 +++++++ .../src/service/DoraDataService.ts | 90 ++++ .../src/service/DoraSyncService.test.ts | 214 ++++++++ .../src/service/DoraSyncService.ts | 215 ++++++++ .../src/service/syncUtils.test.ts | 40 ++ .../src/service/syncUtils.ts | 22 + .../src/service/types.ts | 26 + 34 files changed, 2931 insertions(+), 951 deletions(-) create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/__fixtures__/index.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/__fixtures__/testDatabase.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/mappers.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/mappers.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/types.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockDoraDataService.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/schemas/deploymentSchemas.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraDataService.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraDataService.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/syncUtils.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/syncUtils.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/types.ts diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/migrations/20260723000000_init.js b/workspaces/scorecard/plugins/scorecard-backend-module-dora/migrations/20260723000000_init.js index 10fd53efe92..4953f97bc5e 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/migrations/20260723000000_init.js +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/migrations/20260723000000_init.js @@ -17,23 +17,51 @@ 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(); table.dateTime('created_at', { precision: 0 }).notNullable(); table.string('result').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: 0 }).notNullable(); table.dateTime('updated_at', { precision: 0 }).notNullable(); table.dateTime('resolution_at', { precision: 0 }).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: 0 }).notNullable(); table @@ -42,6 +70,18 @@ exports.up = async function up(knex) { .inTable('dora_deployments') .onDelete('CASCADE') .nullable(); + + 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', + ); }); }; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.test.ts new file mode 100644 index 00000000000..011bae4bc1b --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.test.ts @@ -0,0 +1,114 @@ +/* + * 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. + */ + +import { TestDatabases } from '@backstage/backend-test-utils'; +import { createTestDatabase } from './__fixtures__'; + +jest.setTimeout(60000); + +describe('DatabaseDoraDeployments', () => { + const databases = TestDatabases.create({ + ids: ['POSTGRES_15', 'SQLITE_3'], + }); + + it.each(databases.eachSupportedId())( + 'upserts, reads cursor and window scoped by collector - %p', + async databaseId => { + const { deployments } = await createTestDatabase( + await databases.init(databaseId), + ); + const entityRef = 'component:default/service-a'; + const collectorId = 'github:deployments'; + const otherCollectorId = 'github:deploymentWorkflowRuns'; + + expect( + await deployments.getLatestCreatedAt(entityRef, collectorId), + ).toBeUndefined(); + + await deployments.upsert([ + { + catalogEntityRef: entityRef, + collectorId: collectorId, + originalDeploymentId: 'dep-1', + commitSha: 'sha-1', + environment: 'production', + createdAt: new Date('2026-06-01T10:00:00.000Z'), + result: 'success', + }, + { + catalogEntityRef: entityRef, + collectorId: collectorId, + originalDeploymentId: 'dep-2', + commitSha: 'sha-2', + environment: 'production', + createdAt: new Date('2026-06-10T10:00:00.000Z'), + result: 'success', + }, + { + // Same original id from a different collector is a distinct row + catalogEntityRef: entityRef, + collectorId: otherCollectorId, + originalDeploymentId: 'dep-2', + commitSha: 'sha-other', + environment: 'production', + createdAt: new Date('2026-06-20T10:00:00.000Z'), + result: 'success', + }, + ]); + + const cursor = await deployments.getLatestCreatedAt( + entityRef, + collectorId, + ); + expect(cursor?.toISOString()).toBe('2026-06-10T10:00:00.000Z'); + + await deployments.upsert([ + { + catalogEntityRef: entityRef, + collectorId: collectorId, + originalDeploymentId: 'dep-2', + commitSha: 'sha-2-updated', + environment: 'production', + createdAt: new Date('2026-06-10T10:00:00.000Z'), + result: 'failure', + }, + ]); + + const windowRows = await deployments.readByEntityCollectorAndWindow( + entityRef, + collectorId, + new Date('2026-06-01T00:00:00.000Z'), + new Date('2026-06-30T00:00:00.000Z'), + ); + + expect(windowRows).toHaveLength(2); + expect(windowRows[1].commitSha).toBe('sha-2-updated'); + expect(windowRows[1].result).toBe('failure'); + expect(windowRows[0].id).toEqual(expect.any(String)); + expect(windowRows[1].id).toEqual(expect.any(String)); + }, + ); + + it.each(databases.eachSupportedId())( + 'no-ops when upserting an empty list - %p', + async databaseId => { + const { deployments } = await createTestDatabase( + await databases.init(databaseId), + ); + await expect(deployments.upsert([])).resolves.toBeUndefined(); + }, + ); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.ts new file mode 100644 index 00000000000..fb4bfee9b26 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.ts @@ -0,0 +1,99 @@ +/* + * 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. + */ + +import { Knex } from 'knex'; +import { randomUUID } from 'node:crypto'; +import { + asDate, + fromDoraDeploymentRow, + toDoraDeploymentRow, + type DbDoraDeploymentRow, +} from './mappers'; +import { DbDoraDeployment, DbDoraDeploymentCreate } from './types'; + +export interface DoraDeploymentsStore { + upsert(deployments: DbDoraDeploymentCreate[]): Promise; + getLatestCreatedAt( + catalogEntityRef: string, + collectorId: string, + ): Promise; + readByEntityCollectorAndWindow( + catalogEntityRef: string, + collectorId: string, + from: Date, + to: Date, + ): Promise; +} + +export class DatabaseDoraDeployments implements DoraDeploymentsStore { + private readonly tableName = 'dora_deployments'; + + constructor(private readonly dbClient: Knex) {} + + async upsert(deployments: DbDoraDeploymentCreate[]): Promise { + if (deployments.length === 0) { + return; + } + + await this.dbClient(this.tableName) + .insert( + deployments.map(deployment => ({ + ...toDoraDeploymentRow(deployment), + id: randomUUID(), + })), + ) + .onConflict([ + 'catalog_entity_ref', + 'collector_id', + 'original_deployment_id', + ]) + .merge(['commit_sha', 'environment', 'created_at', 'result']); + } + + async getLatestCreatedAt( + catalogEntityRef: string, + collectorId: string, + ): Promise { + const row = await this.dbClient(this.tableName) + .where('catalog_entity_ref', catalogEntityRef) + .andWhere('collector_id', collectorId) + .max('created_at as latest') + .first(); + + if (!row?.latest) { + return undefined; + } + + return asDate(row.latest); + } + + async readByEntityCollectorAndWindow( + catalogEntityRef: string, + collectorId: string, + from: Date, + to: Date, + ): Promise { + const rows = await this.dbClient(this.tableName) + .select('*') + .where('catalog_entity_ref', catalogEntityRef) + .andWhere('collector_id', collectorId) + .andWhere('created_at', '>=', from) + .andWhere('created_at', '<=', to) + .orderBy('created_at', 'asc'); + + return rows.map(fromDoraDeploymentRow); + } +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.test.ts new file mode 100644 index 00000000000..93d9a6d035e --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.test.ts @@ -0,0 +1,91 @@ +/* + * 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. + */ + +import { TestDatabases } from '@backstage/backend-test-utils'; +import { createTestDatabase } from './__fixtures__'; + +jest.setTimeout(60000); + +describe('DatabaseDoraIncidents', () => { + const databases = TestDatabases.create({ + ids: ['POSTGRES_15', 'SQLITE_3'], + }); + + it.each(databases.eachSupportedId())( + 'upserts resolution updates and reads window - %p', + async databaseId => { + const { incidents } = await createTestDatabase( + await databases.init(databaseId), + ); + const entityRef = 'component:default/service-a'; + const collectorId = 'jira:incidents'; + + expect( + await incidents.getLatestUpdatedAt(entityRef, collectorId), + ).toBeUndefined(); + + await incidents.upsert([ + { + catalogEntityRef: entityRef, + collectorId: collectorId, + originalIncidentId: 'INC-1', + createdAt: new Date('2026-06-01T10:00:00.000Z'), + updatedAt: new Date('2026-06-01T10:00:00.000Z'), + resolutionAt: null, + }, + ]); + + await incidents.upsert([ + { + catalogEntityRef: entityRef, + collectorId: collectorId, + originalIncidentId: 'INC-1', + createdAt: new Date('2026-06-01T10:00:00.000Z'), + updatedAt: new Date('2026-06-02T12:00:00.000Z'), + resolutionAt: new Date('2026-06-02T12:00:00.000Z'), + }, + ]); + + const rows = await incidents.readByEntityCollectorAndWindow( + entityRef, + collectorId, + new Date('2026-06-01T00:00:00.000Z'), + new Date('2026-06-30T00:00:00.000Z'), + ); + + expect(rows).toHaveLength(1); + expect(rows[0].id).toEqual(expect.any(String)); + expect(rows[0].resolutionAt?.toISOString()).toBe( + '2026-06-02T12:00:00.000Z', + ); + expect( + ( + await incidents.getLatestUpdatedAt(entityRef, collectorId) + )?.toISOString(), + ).toBe('2026-06-02T12:00:00.000Z'); + }, + ); + + it.each(databases.eachSupportedId())( + 'no-ops when upserting an empty list - %p', + async databaseId => { + const { incidents } = await createTestDatabase( + await databases.init(databaseId), + ); + await expect(incidents.upsert([])).resolves.toBeUndefined(); + }, + ); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.ts new file mode 100644 index 00000000000..f8ecbdb18da --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.ts @@ -0,0 +1,99 @@ +/* + * 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. + */ + +import { Knex } from 'knex'; +import { randomUUID } from 'node:crypto'; +import { + asDate, + fromDoraIncidentRow, + toDoraIncidentRow, + type DbDoraIncidentRow, +} from './mappers'; +import { DbDoraIncident, DbDoraIncidentCreate } from './types'; + +export interface DoraIncidentsStore { + upsert(incidents: DbDoraIncidentCreate[]): Promise; + getLatestUpdatedAt( + catalogEntityRef: string, + collectorId: string, + ): Promise; + readByEntityCollectorAndWindow( + catalogEntityRef: string, + collectorId: string, + from: Date, + to: Date, + ): Promise; +} + +export class DatabaseDoraIncidents implements DoraIncidentsStore { + private readonly tableName = 'dora_incidents'; + + constructor(private readonly dbClient: Knex) {} + + async upsert(incidents: DbDoraIncidentCreate[]): Promise { + if (incidents.length === 0) { + return; + } + + await this.dbClient(this.tableName) + .insert( + incidents.map(incident => ({ + ...toDoraIncidentRow(incident), + id: randomUUID(), + })), + ) + .onConflict([ + 'catalog_entity_ref', + 'collector_id', + 'original_incident_id', + ]) + .merge(['created_at', 'updated_at', 'resolution_at']); + } + + async getLatestUpdatedAt( + catalogEntityRef: string, + collectorId: string, + ): Promise { + const row = await this.dbClient(this.tableName) + .where('catalog_entity_ref', catalogEntityRef) + .andWhere('collector_id', collectorId) + .max('updated_at as latest') + .first(); + + if (!row?.latest) { + return undefined; + } + + return asDate(row.latest); + } + + async readByEntityCollectorAndWindow( + catalogEntityRef: string, + collectorId: string, + from: Date, + to: Date, + ): Promise { + const rows = await this.dbClient(this.tableName) + .select('*') + .where('catalog_entity_ref', catalogEntityRef) + .andWhere('collector_id', collectorId) + .andWhere('created_at', '>=', from) + .andWhere('created_at', '<=', to) + .orderBy('created_at', 'asc'); + + return rows.map(fromDoraIncidentRow); + } +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.test.ts new file mode 100644 index 00000000000..bfee6eaa1d0 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.test.ts @@ -0,0 +1,146 @@ +/* + * 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. + */ + +import { TestDatabases } from '@backstage/backend-test-utils'; +import { createTestDatabase } from './__fixtures__'; + +jest.setTimeout(60000); + +describe('DatabaseDoraPullRequests', () => { + const databases = TestDatabases.create({ + ids: ['POSTGRES_15', 'SQLITE_3'], + }); + + it.each(databases.eachSupportedId())( + 'upserts and reads by deployment - %p', + async databaseId => { + const { deployments, pullRequests } = await createTestDatabase( + await databases.init(databaseId), + ); + const entityRef = 'component:default/service-a'; + const deploymentsCollectorId = 'github:deployments'; + const prCollectorId = 'github:deploymentRangePullRequests'; + + await deployments.upsert([ + { + catalogEntityRef: entityRef, + collectorId: deploymentsCollectorId, + originalDeploymentId: 'dep-1', + commitSha: 'sha-1', + environment: 'production', + createdAt: new Date('2026-06-10T10:00:00.000Z'), + result: 'success', + }, + ]); + const [deployment] = await deployments.readByEntityCollectorAndWindow( + entityRef, + deploymentsCollectorId, + new Date('2026-06-01T00:00:00.000Z'), + new Date('2026-06-30T00:00:00.000Z'), + ); + + await pullRequests.upsert([ + { + catalogEntityRef: entityRef, + collectorId: prCollectorId, + originalPrId: 'pr-1', + firstCommitAt: new Date('2026-06-09T10:00:00.000Z'), + deploymentId: deployment.id, + }, + ]); + + const rows = await pullRequests.readByEntityCollectorAndDeployment( + entityRef, + prCollectorId, + deployment.id, + ); + + expect(rows).toHaveLength(1); + expect(rows[0].originalPrId).toBe('pr-1'); + expect(rows[0].deploymentId).toBe(deployment.id); + expect(rows[0].id).toEqual(expect.any(String)); + }, + ); + + it.each(databases.eachSupportedId())( + 'merges pull request updates on natural key conflict - %p', + async databaseId => { + const { deployments, pullRequests } = await createTestDatabase( + await databases.init(databaseId), + ); + const entityRef = 'component:default/service-a'; + const prCollectorId = 'github:deploymentRangePullRequests'; + + await deployments.upsert([ + { + catalogEntityRef: entityRef, + collectorId: 'github:deployments', + originalDeploymentId: 'dep-1', + commitSha: 'sha-1', + environment: 'production', + createdAt: new Date('2026-06-10T10:00:00.000Z'), + result: 'success', + }, + ]); + const [deployment] = await deployments.readByEntityCollectorAndWindow( + entityRef, + 'github:deployments', + new Date('2026-06-01T00:00:00.000Z'), + new Date('2026-06-30T00:00:00.000Z'), + ); + + await pullRequests.upsert([ + { + catalogEntityRef: entityRef, + collectorId: prCollectorId, + originalPrId: 'pr-1', + firstCommitAt: new Date('2026-06-09T10:00:00.000Z'), + deploymentId: deployment.id, + }, + ]); + await pullRequests.upsert([ + { + catalogEntityRef: entityRef, + collectorId: prCollectorId, + originalPrId: 'pr-1', + firstCommitAt: new Date('2026-06-09T12:00:00.000Z'), + deploymentId: deployment.id, + }, + ]); + + const rows = await pullRequests.readByEntityCollectorAndDeployment( + entityRef, + prCollectorId, + deployment.id, + ); + + expect(rows).toHaveLength(1); + expect(rows[0].firstCommitAt.toISOString()).toBe( + '2026-06-09T12:00:00.000Z', + ); + }, + ); + + it.each(databases.eachSupportedId())( + 'no-ops when upserting an empty list - %p', + async databaseId => { + const { pullRequests } = await createTestDatabase( + await databases.init(databaseId), + ); + await expect(pullRequests.upsert([])).resolves.toBeUndefined(); + }, + ); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.ts new file mode 100644 index 00000000000..dcf6b643e5f --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.ts @@ -0,0 +1,75 @@ +/* + * 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. + */ + +import { Knex } from 'knex'; +import { randomUUID } from 'node:crypto'; +import { + fromDoraPullRequestRow, + toDoraPullRequestRow, + type DbDoraPullRequestRow, +} from './mappers'; +import { DbDoraPullRequest, DbDoraPullRequestCreate } from './types'; + +export interface DoraPullRequestsStore { + upsert(pullRequests: DbDoraPullRequestCreate[]): Promise; + readByEntityCollectorAndDeployment( + catalogEntityRef: string, + collectorId: string, + deploymentId: string, + ): Promise; +} + +export class DatabaseDoraPullRequests implements DoraPullRequestsStore { + private readonly tableName = 'dora_pull_requests'; + + constructor(private readonly dbClient: Knex) {} + + async upsert(pullRequests: DbDoraPullRequestCreate[]): Promise { + if (pullRequests.length === 0) { + return; + } + + await this.dbClient(this.tableName) + .insert( + pullRequests.map(pullRequest => ({ + ...toDoraPullRequestRow(pullRequest), + id: randomUUID(), + })), + ) + .onConflict([ + 'catalog_entity_ref', + 'collector_id', + 'original_pr_id', + 'deployment_id', + ]) + .merge(['first_commit_at']); + } + + async readByEntityCollectorAndDeployment( + catalogEntityRef: string, + collectorId: string, + deploymentId: string, + ): Promise { + const rows = await this.dbClient(this.tableName) + .select('*') + .where('catalog_entity_ref', catalogEntityRef) + .andWhere('collector_id', collectorId) + .andWhere('deployment_id', deploymentId) + .orderBy('first_commit_at', 'asc'); + + return rows.map(fromDoraPullRequestRow); + } +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/__fixtures__/index.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/__fixtures__/index.ts new file mode 100644 index 00000000000..f6f0f04e98e --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/__fixtures__/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export * from './testDatabase'; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/__fixtures__/testDatabase.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/__fixtures__/testDatabase.ts new file mode 100644 index 00000000000..fe0d1ecc927 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/__fixtures__/testDatabase.ts @@ -0,0 +1,40 @@ +/* + * 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. + */ + +import { resolvePackagePath } from '@backstage/backend-plugin-api'; +import type { Knex } from 'knex'; +import { DatabaseDoraDeployments } from '../DatabaseDoraDeployments'; +import { DatabaseDoraIncidents } from '../DatabaseDoraIncidents'; +import { DatabaseDoraPullRequests } from '../DatabaseDoraPullRequests'; + +const migrationsDir = resolvePackagePath( + '@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-dora', + 'migrations', +); + +export async function createTestDatabase(client: Knex) { + await client.migrate.latest({ + directory: migrationsDir, + tableName: 'dora_knex_migrations', + }); + + return { + client, + deployments: new DatabaseDoraDeployments(client), + incidents: new DatabaseDoraIncidents(client), + pullRequests: new DatabaseDoraPullRequests(client), + }; +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/mappers.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/mappers.test.ts new file mode 100644 index 00000000000..38421cb0b06 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/mappers.test.ts @@ -0,0 +1,225 @@ +/* + * 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. + */ + +import { + asDate, + fromDoraDeploymentRow, + fromDoraIncidentRow, + fromDoraPullRequestRow, + toDoraDeploymentRow, + toDoraIncidentRow, + toDoraPullRequestRow, +} from './mappers'; + +describe('mappers', () => { + describe('asDate', () => { + it('returns the same Date instance when given a Date', () => { + const value = new Date('2026-06-01T00:00:00.000Z'); + expect(asDate(value)).toBe(value); + }); + + it('parses ISO strings into Date', () => { + expect(asDate('2026-06-01T00:00:00.000Z').toISOString()).toBe( + '2026-06-01T00:00:00.000Z', + ); + }); + + it('throws for empty strings', () => { + expect(() => asDate('')).toThrow(/Invalid timestamp/); + }); + + it('throws for invalid date strings', () => { + expect(() => asDate('not-a-date')).toThrow(/Invalid timestamp/); + }); + + it('throws for invalid Date instances', () => { + expect(() => asDate(new Date(Number.NaN))).toThrow(/Invalid timestamp/); + }); + }); + + describe('deployments', () => { + it('maps create model to snake_case row fields', () => { + const createdAt = new Date('2026-06-10T10:00:00.000Z'); + const create = { + catalogEntityRef: 'component:default/service-a', + collectorId: 'github:deployments', + originalDeploymentId: 'dep-1', + commitSha: 'sha-1', + environment: 'production', + createdAt, + result: 'success', + }; + + expect(toDoraDeploymentRow(create)).toEqual({ + catalog_entity_ref: 'component:default/service-a', + collector_id: 'github:deployments', + original_deployment_id: 'dep-1', + commit_sha: 'sha-1', + environment: 'production', + created_at: createdAt, + result: 'success', + }); + }); + + it('defaults missing environment to null', () => { + expect( + toDoraDeploymentRow({ + catalogEntityRef: 'component:default/service-a', + collectorId: 'github:deployments', + originalDeploymentId: 'dep-1', + commitSha: 'sha-1', + createdAt: new Date('2026-06-10T10:00:00.000Z'), + result: 'success', + }).environment, + ).toBeNull(); + }); + + it('maps database row to camelCase including id', () => { + expect( + fromDoraDeploymentRow({ + id: 'dep-row-1', + catalog_entity_ref: 'component:default/service-a', + collector_id: 'github:deployments', + original_deployment_id: 'dep-1', + commit_sha: 'sha-1', + environment: null, + created_at: '2026-06-10T10:00:00.000Z', + result: 'success', + }), + ).toEqual({ + id: 'dep-row-1', + catalogEntityRef: 'component:default/service-a', + collectorId: 'github:deployments', + originalDeploymentId: 'dep-1', + commitSha: 'sha-1', + environment: null, + createdAt: new Date('2026-06-10T10:00:00.000Z'), + result: 'success', + }); + }); + }); + + describe('incidents', () => { + it('maps create model to snake_case row fields', () => { + const createdAt = new Date('2026-06-11T10:00:00.000Z'); + const updatedAt = new Date('2026-06-11T12:00:00.000Z'); + const resolutionAt = new Date('2026-06-11T12:00:00.000Z'); + const create = { + catalogEntityRef: 'component:default/service-a', + collectorId: 'jira:incidents', + originalIncidentId: 'INC-1', + createdAt, + updatedAt, + resolutionAt, + }; + + expect(toDoraIncidentRow(create)).toEqual({ + catalog_entity_ref: 'component:default/service-a', + collector_id: 'jira:incidents', + original_incident_id: 'INC-1', + created_at: createdAt, + updated_at: updatedAt, + resolution_at: resolutionAt, + }); + }); + + it('defaults missing resolutionAt to null', () => { + expect( + toDoraIncidentRow({ + catalogEntityRef: 'component:default/service-a', + collectorId: 'jira:incidents', + originalIncidentId: 'INC-1', + createdAt: new Date('2026-06-11T10:00:00.000Z'), + updatedAt: new Date('2026-06-11T10:00:00.000Z'), + }).resolution_at, + ).toBeNull(); + }); + + it('maps null resolution_at and parses string timestamps', () => { + expect( + fromDoraIncidentRow({ + id: 'inc-row-1', + catalog_entity_ref: 'component:default/service-a', + collector_id: 'jira:incidents', + original_incident_id: 'INC-1', + created_at: '2026-06-11T10:00:00.000Z', + updated_at: '2026-06-11T12:00:00.000Z', + resolution_at: null, + }), + ).toEqual({ + id: 'inc-row-1', + catalogEntityRef: 'component:default/service-a', + collectorId: 'jira:incidents', + originalIncidentId: 'INC-1', + createdAt: new Date('2026-06-11T10:00:00.000Z'), + updatedAt: new Date('2026-06-11T12:00:00.000Z'), + resolutionAt: null, + }); + }); + }); + + describe('pull requests', () => { + it('maps create model to snake_case row fields', () => { + const firstCommitAt = new Date('2026-06-09T10:00:00.000Z'); + const create = { + catalogEntityRef: 'component:default/service-a', + collectorId: 'github:deploymentPullRequests', + originalPrId: 'pr-1', + firstCommitAt, + deploymentId: 'dep-row-1', + }; + + expect(toDoraPullRequestRow(create)).toEqual({ + catalog_entity_ref: 'component:default/service-a', + collector_id: 'github:deploymentPullRequests', + original_pr_id: 'pr-1', + first_commit_at: firstCommitAt, + deployment_id: 'dep-row-1', + }); + }); + + it('defaults missing deploymentId to null', () => { + expect( + toDoraPullRequestRow({ + catalogEntityRef: 'component:default/service-a', + collectorId: 'github:deploymentPullRequests', + originalPrId: 'pr-1', + firstCommitAt: new Date('2026-06-09T10:00:00.000Z'), + }).deployment_id, + ).toBeNull(); + }); + + it('maps database row to camelCase including id', () => { + expect( + fromDoraPullRequestRow({ + id: 'pr-row-1', + catalog_entity_ref: 'component:default/service-a', + collector_id: 'github:deploymentPullRequests', + original_pr_id: 'pr-1', + first_commit_at: '2026-06-09T10:00:00.000Z', + deployment_id: null, + }), + ).toEqual({ + id: 'pr-row-1', + catalogEntityRef: 'component:default/service-a', + collectorId: 'github:deploymentPullRequests', + originalPrId: 'pr-1', + firstCommitAt: new Date('2026-06-09T10:00:00.000Z'), + deploymentId: null, + }); + }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/mappers.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/mappers.ts new file mode 100644 index 00000000000..2d42a9878c7 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/mappers.ts @@ -0,0 +1,141 @@ +/* + * 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. + */ + +import type { + DbDoraDeployment, + DbDoraDeploymentCreate, + DbDoraIncident, + DbDoraIncidentCreate, + DbDoraPullRequest, + DbDoraPullRequestCreate, +} from './types'; + +export type DbDoraDeploymentRow = { + id: string; + catalog_entity_ref: string; + collector_id: string; + original_deployment_id: string; + commit_sha: string; + environment: string | null; + created_at: Date | string; + result: string; +}; + +export type DbDoraIncidentRow = { + id: string; + catalog_entity_ref: string; + collector_id: string; + original_incident_id: string; + created_at: Date | string; + updated_at: Date | string; + resolution_at: Date | string | null; +}; + +export type DbDoraPullRequestRow = { + id: string; + catalog_entity_ref: string; + collector_id: string; + original_pr_id: string; + first_commit_at: Date | string; + deployment_id: string | null; +}; + +export function toDoraDeploymentRow( + deployment: DbDoraDeploymentCreate, +): Omit { + return { + catalog_entity_ref: deployment.catalogEntityRef, + collector_id: deployment.collectorId, + original_deployment_id: deployment.originalDeploymentId, + commit_sha: deployment.commitSha, + environment: deployment.environment ?? null, + created_at: deployment.createdAt, + result: deployment.result, + }; +} + +export function fromDoraDeploymentRow( + row: DbDoraDeploymentRow, +): DbDoraDeployment { + return { + id: row.id, + catalogEntityRef: row.catalog_entity_ref, + collectorId: row.collector_id, + originalDeploymentId: row.original_deployment_id, + commitSha: row.commit_sha, + environment: row.environment, + createdAt: asDate(row.created_at), + result: row.result, + }; +} + +export function toDoraIncidentRow( + incident: DbDoraIncidentCreate, +): Omit { + return { + catalog_entity_ref: incident.catalogEntityRef, + collector_id: incident.collectorId, + original_incident_id: incident.originalIncidentId, + created_at: incident.createdAt, + updated_at: incident.updatedAt, + resolution_at: incident.resolutionAt ?? null, + }; +} + +export function fromDoraIncidentRow(row: DbDoraIncidentRow): DbDoraIncident { + return { + id: row.id, + catalogEntityRef: row.catalog_entity_ref, + collectorId: row.collector_id, + originalIncidentId: row.original_incident_id, + createdAt: asDate(row.created_at), + updatedAt: asDate(row.updated_at), + resolutionAt: row.resolution_at ? asDate(row.resolution_at) : null, + }; +} + +export function toDoraPullRequestRow( + pullRequest: DbDoraPullRequestCreate, +): Omit { + return { + catalog_entity_ref: pullRequest.catalogEntityRef, + collector_id: pullRequest.collectorId, + original_pr_id: pullRequest.originalPrId, + first_commit_at: pullRequest.firstCommitAt, + deployment_id: pullRequest.deploymentId ?? null, + }; +} + +export function fromDoraPullRequestRow( + row: DbDoraPullRequestRow, +): DbDoraPullRequest { + return { + id: row.id, + catalogEntityRef: row.catalog_entity_ref, + collectorId: row.collector_id, + originalPrId: row.original_pr_id, + firstCommitAt: asDate(row.first_commit_at), + deploymentId: row.deployment_id, + }; +} + +export function asDate(value: Date | string): Date { + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) { + throw new Error(`Invalid timestamp: ${String(value)}`); + } + return date; +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/types.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/types.ts new file mode 100644 index 00000000000..da3eef302db --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/types.ts @@ -0,0 +1,72 @@ +/* + * 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. + */ + +export type DbDoraDeploymentCreate = { + catalogEntityRef: string; + collectorId: string; + originalDeploymentId: string; + commitSha: string; + environment?: string | null; + createdAt: Date; + result: string; +}; + +export type DbDoraDeployment = { + id: string; + catalogEntityRef: string; + collectorId: string; + originalDeploymentId: string; + commitSha: string; + environment: string | null; + createdAt: Date; + result: string; +}; + +export type DbDoraIncidentCreate = { + catalogEntityRef: string; + collectorId: string; + originalIncidentId: string; + createdAt: Date; + updatedAt: Date; + resolutionAt?: Date | null; +}; + +export type DbDoraIncident = { + id: string; + catalogEntityRef: string; + collectorId: string; + originalIncidentId: string; + createdAt: Date; + updatedAt: Date; + resolutionAt: Date | null; +}; + +export type DbDoraPullRequestCreate = { + catalogEntityRef: string; + collectorId: string; + originalPrId: string; + firstCommitAt: Date; + deploymentId?: string | null; +}; + +export type DbDoraPullRequest = { + id: string; + catalogEntityRef: string; + collectorId: string; + originalPrId: string; + firstCommitAt: Date; + deploymentId: string | null; +}; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraChangeFailureRateProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraChangeFailureRateProvider.test.ts index f4748bd3663..5b620b53780 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraChangeFailureRateProvider.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraChangeFailureRateProvider.test.ts @@ -14,68 +14,59 @@ * limitations under the License. */ -import { mockServices } from '@backstage/backend-test-utils'; import { ConfigReader } from '@backstage/config'; +import { mockServices } from '@backstage/backend-test-utils'; import { DoraChangeFailureRateProvider } from './DoraChangeFailureRateProvider'; import { - buildMockCollectorsService, - buildMockDeploymentsCollector, - buildMockIncidentsCollector, + buildMockDoraServices, + dbDeployment, + dbIncident, mockEntity, } from './__fixtures__'; +import type { DoraDataService } from '../service/DoraDataService'; +import type { DoraSyncService } from '../service/DoraSyncService'; import { DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, } from '../constants'; import { DEFAULT_DORA_CHANGE_FAILURE_RATE_THRESHOLDS } from './DoraConfig'; -const mockLogger = mockServices.logger.mock(); - describe('DoraChangeFailureRateProvider', () => { - let deploymentsCollector: ReturnType; - let incidentsCollector: ReturnType; - let collectorsService: ReturnType< - typeof buildMockCollectorsService - >['collectorsService']; - let collect: ReturnType['collect']; + const mockLogger = mockServices.logger.mock(); + let doraSyncService: jest.Mocked; + let doraDataService: jest.Mocked; let provider: DoraChangeFailureRateProvider; beforeEach(() => { - jest.clearAllMocks(); - deploymentsCollector = buildMockDeploymentsCollector({ + ({ doraSyncService, doraDataService } = buildMockDoraServices({ deployments: [ - { + dbDeployment({ id: '100', commitSha: 'sha-1', environment: 'production', createdAt: '2026-06-10T00:00:00.000Z', result: 'success', - }, - { + }), + dbDeployment({ id: '101', commitSha: 'sha-2', environment: 'production', createdAt: '2026-06-11T00:00:00.000Z', result: 'success', - }, + }), ], - collectorId: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, - }); - incidentsCollector = buildMockIncidentsCollector({ incidents: [ - { + dbIncident({ id: 'INC-1', createdAt: '2026-06-10T12:00:00.000Z', + updatedAt: '2026-06-10T13:00:00.000Z', resolutionAt: '2026-06-10T13:00:00.000Z', - }, + }), ], - collectorId: DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, - }); - ({ collectorsService, collect } = buildMockCollectorsService({ - collectors: [deploymentsCollector, incidentsCollector], })); provider = DoraChangeFailureRateProvider.fromConfig(new ConfigReader({}), { - collectorsService, + doraSyncService, + doraDataService, logger: mockLogger, }); }); @@ -93,17 +84,23 @@ describe('DoraChangeFailureRateProvider', () => { }); describe('calculateMetrics', () => { - it('should use default collectors when no config', async () => { + it('should sync deployments and incidents with default collectors', async () => { await provider.calculateMetrics(mockEntity); - expect(collect).toHaveBeenCalledWith( + expect(doraSyncService.syncDeployments).toHaveBeenCalledWith( + mockEntity, expect.objectContaining({ - collectorId: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + collector: expect.objectContaining({ + id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + }), }), ); - expect(collect).toHaveBeenCalledWith( + expect(doraSyncService.syncIncidents).toHaveBeenCalledWith( + mockEntity, expect.objectContaining({ - collectorId: DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, + collector: expect.objectContaining({ + id: DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, + }), }), ); }); @@ -111,41 +108,33 @@ describe('DoraChangeFailureRateProvider', () => { it('should use custom collectors and pass custom inputs', async () => { const customDeploymentsCollectorId = 'custom:deployments'; const customIncidentsCollectorId = 'custom:incidents'; - const customDeploymentsCollector = buildMockDeploymentsCollector({ - deployments: [ - { - id: '100', - commitSha: 'sha-1', - environment: 'production', - createdAt: '2026-06-10T00:00:00.000Z', - result: 'success', - }, - { - id: '101', - commitSha: 'sha-2', - environment: 'production', - createdAt: '2026-06-11T00:00:00.000Z', - result: 'success', - }, - ], - collectorId: customDeploymentsCollectorId, - }); - const customIncidentsCollector = buildMockIncidentsCollector({ - incidents: [ - { - id: 'INC-1', - createdAt: '2026-06-10T12:00:00.000Z', - resolutionAt: null, - }, - ], - collectorId: customIncidentsCollectorId, - }); - const { - collectorsService: customCollectorsService, - collect: customCollect, - } = buildMockCollectorsService({ - collectors: [customDeploymentsCollector, customIncidentsCollector], - }); + const { doraSyncService: sync, doraDataService: data } = + buildMockDoraServices({ + deployments: [ + dbDeployment({ + id: '100', + commitSha: 'sha-1', + environment: 'production', + createdAt: '2026-06-10T00:00:00.000Z', + result: 'success', + }), + dbDeployment({ + id: '101', + commitSha: 'sha-2', + environment: 'production', + createdAt: '2026-06-11T00:00:00.000Z', + result: 'success', + }), + ], + incidents: [ + dbIncident({ + id: 'INC-1', + createdAt: '2026-06-10T12:00:00.000Z', + updatedAt: '2026-06-10T12:00:00.000Z', + resolutionAt: null, + }), + ], + }); const customProvider = DoraChangeFailureRateProvider.fromConfig( new ConfigReader({ scorecard: { @@ -175,75 +164,76 @@ describe('DoraChangeFailureRateProvider', () => { }, }), { - collectorsService: customCollectorsService, + doraSyncService: sync, + doraDataService: data, logger: mockLogger, }, ); await customProvider.calculateMetrics(mockEntity); - expect(customCollect).toHaveBeenCalledWith( + expect(sync.syncDeployments).toHaveBeenCalledWith( + mockEntity, expect.objectContaining({ - collectorId: customDeploymentsCollectorId, - input: expect.objectContaining({ - from: expect.any(String), - to: expect.any(String), - customDeploymentsInputLabel: 'deployments-custom-input', + collector: expect.objectContaining({ + id: customDeploymentsCollectorId, + input: expect.objectContaining({ + customDeploymentsInputLabel: 'deployments-custom-input', + }), }), }), ); - expect(customCollect).toHaveBeenCalledWith( + expect(sync.syncIncidents).toHaveBeenCalledWith( + mockEntity, expect.objectContaining({ - collectorId: customIncidentsCollectorId, - input: expect.objectContaining({ - from: expect.any(String), - to: expect.any(String), - customIncidentsInputLabel: 'incidents-custom-input', + collector: expect.objectContaining({ + id: customIncidentsCollectorId, + input: expect.objectContaining({ + customIncidentsInputLabel: 'incidents-custom-input', + }), }), }), ); }); it('should calculate change failure rate using incidents between successful deployments', async () => { - jest.mocked(deploymentsCollector.collect).mockResolvedValueOnce({ - deployments: [ - { - id: '100', - commitSha: 'sha-1', - environment: 'production', - createdAt: '2026-06-10T00:00:00.000Z', - result: 'success', - }, - { - id: '101', - commitSha: 'sha-2', - environment: 'production', - createdAt: '2026-06-11T00:00:00.000Z', - result: 'success', - }, - { - id: '102', - commitSha: 'sha-3', - environment: 'production', - createdAt: '2026-06-12T00:00:00.000Z', - result: 'success', - }, - ], - }); - jest.mocked(incidentsCollector.collect).mockResolvedValueOnce({ - incidents: [ - { - id: 'INC-1', - createdAt: '2026-06-10T06:00:00.000Z', // for deployment 100 - resolutionAt: null, - }, - { - id: 'INC-2', - createdAt: '2026-06-12T05:00:00.000Z', // after last pair boundary - resolutionAt: null, - }, - ], - }); + doraDataService.readDeployments.mockResolvedValueOnce([ + dbDeployment({ + id: '100', + commitSha: 'sha-1', + environment: 'production', + createdAt: '2026-06-10T00:00:00.000Z', + result: 'success', + }), + dbDeployment({ + id: '101', + commitSha: 'sha-2', + environment: 'production', + createdAt: '2026-06-11T00:00:00.000Z', + result: 'success', + }), + dbDeployment({ + id: '102', + commitSha: 'sha-3', + environment: 'production', + createdAt: '2026-06-12T00:00:00.000Z', + result: 'success', + }), + ]); + doraDataService.readIncidents.mockResolvedValueOnce([ + dbIncident({ + id: 'INC-1', + createdAt: '2026-06-10T06:00:00.000Z', // for deployment 100 + updatedAt: '2026-06-10T06:00:00.000Z', + resolutionAt: null, + }), + dbIncident({ + id: 'INC-2', + createdAt: '2026-06-12T05:00:00.000Z', // after last pair boundary + updatedAt: '2026-06-12T05:00:00.000Z', + resolutionAt: null, + }), + ]); const results = await provider.calculateMetrics(mockEntity); @@ -251,35 +241,38 @@ describe('DoraChangeFailureRateProvider', () => { }); it('should throw when fewer than 2 successful production deployments are found', async () => { - jest.mocked(deploymentsCollector.collect).mockResolvedValueOnce({ - deployments: [], - }); + doraDataService.readDeployments.mockResolvedValueOnce([ + dbDeployment({ + id: '100', + commitSha: 'sha-1', + environment: 'production', + createdAt: '2026-06-10T00:00:00.000Z', + result: 'success', + }), + ]); await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( /need at least 2 successful production deployments/, ); - expect(incidentsCollector.collect).not.toHaveBeenCalled(); }); it('should throw when fewer than 2 successful deployments are found', async () => { - jest.mocked(deploymentsCollector.collect).mockResolvedValueOnce({ - deployments: [ - { - id: '100', - commitSha: 'sha-1', - environment: 'production', - createdAt: '2026-06-10T00:00:00.000Z', - result: 'success', - }, - { - id: '101', - commitSha: 'sha-2', - environment: 'production', - createdAt: '2026-06-11T00:00:00.000Z', - result: 'failure', - }, - ], - }); + doraDataService.readDeployments.mockResolvedValueOnce([ + dbDeployment({ + id: '100', + commitSha: 'sha-1', + environment: 'production', + createdAt: '2026-06-10T00:00:00.000Z', + result: 'success', + }), + dbDeployment({ + id: '101', + commitSha: 'sha-2', + environment: 'production', + createdAt: '2026-06-11T00:00:00.000Z', + result: 'failure', + }), + ]); await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( /need at least 2 successful production deployments.*found 1/, @@ -287,152 +280,45 @@ describe('DoraChangeFailureRateProvider', () => { }); it('should throw when fewer than two production deployments are found', async () => { - jest.mocked(deploymentsCollector.collect).mockResolvedValueOnce({ - deployments: [ - { - id: '100', - commitSha: 'sha-1', - environment: 'production', - createdAt: '2026-06-10T00:00:00.000Z', - result: 'success', - }, - { - id: '101', - commitSha: 'sha-2', - environment: 'demo-test', - createdAt: '2026-06-11T00:00:00.000Z', - result: 'success', - }, - ], - }); - - await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( - /need at least 2 successful production deployments.*found 1/, - ); - }); - - it('should use configured productionEnvironments when filtering deployments', async () => { - const customProvider = DoraChangeFailureRateProvider.fromConfig( - new ConfigReader({ - scorecard: { - metricProviders: { - dora: { - changeFailureRate: { - options: { - productionEnvironments: ['prod'], - }, - }, - }, - }, - }, + doraDataService.readDeployments.mockResolvedValueOnce([ + dbDeployment({ + id: '100', + commitSha: 'sha-1', + environment: 'production', + createdAt: '2026-06-10T00:00:00.000Z', + result: 'success', }), - { - collectorsService, - logger: mockLogger, - }, - ); - - jest.mocked(deploymentsCollector.collect).mockResolvedValueOnce({ - deployments: [ - { - id: '400', - commitSha: 'sha-1', - environment: 'production', - createdAt: '2026-06-10T00:00:00.000Z', - result: 'success', - }, - { - id: '401', - commitSha: 'sha-2', - environment: 'prod', - createdAt: '2026-06-11T00:00:00.000Z', - result: 'success', - }, - ], - }); + dbDeployment({ + id: '101', + commitSha: 'sha-2', + environment: 'demo-test', + createdAt: '2026-06-11T00:00:00.000Z', + result: 'success', + }), + ]); - await expect(customProvider.calculateMetrics(mockEntity)).rejects.toThrow( + await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( /need at least 2 successful production deployments.*found 1/, ); }); - it('should return 0 when evaluated intervals have no incidents', async () => { - jest.mocked(incidentsCollector.collect).mockResolvedValueOnce({ - incidents: [], - }); - - const results = await provider.calculateMetrics(mockEntity); - - expect(results.get('dora.changeFailureRate')).toBe(0); - }); - - it('should attribute an incident after last successful production deployment to the following DORA interval', async () => { - jest.mocked(deploymentsCollector.collect).mockResolvedValueOnce({ - deployments: [ - { - id: '100', - commitSha: 'sha-1', - environment: 'production', - createdAt: '2026-06-10T00:00:00.000Z', - result: 'success', - }, - { - id: '101', - commitSha: 'sha-2', - environment: 'production', - createdAt: '2026-06-11T10:00:00.000Z', - result: 'success', - }, - { - id: '102', - commitSha: 'sha-3', - environment: 'production', - createdAt: '2026-06-12T00:00:00.000Z', - result: 'success', - }, - ], - }); - jest.mocked(incidentsCollector.collect).mockResolvedValueOnce({ - incidents: [ - { - id: 'INC-1', - // Belongs to [sha-2, sha-3] - createdAt: '2026-06-11T00:00:00.000Z', - resolutionAt: null, - }, - { - id: 'INC-2', - // After last successful deployment sha-3, not counted - createdAt: '2026-06-13T00:00:00.000Z', - resolutionAt: null, - }, - ], - }); - - const results = await provider.calculateMetrics(mockEntity); - - expect(results.get('dora.changeFailureRate')).toBe(50); // 1 of 2 intervals - }); - it('should throw when all adjacent successful production deployments share createdAt', async () => { - jest.mocked(deploymentsCollector.collect).mockResolvedValueOnce({ - deployments: [ - { - id: '100', - commitSha: 'sha-1', - environment: 'production', - createdAt: '2026-06-10T00:00:00.000Z', - result: 'success', - }, - { - id: '101', - commitSha: 'sha-2', - environment: 'production', - createdAt: '2026-06-10T00:00:00.000Z', - result: 'success', - }, - ], - }); + doraDataService.readDeployments.mockResolvedValueOnce([ + dbDeployment({ + id: '100', + commitSha: 'sha-1', + environment: 'production', + createdAt: '2026-06-10T00:00:00.000Z', + result: 'success', + }), + dbDeployment({ + id: '101', + commitSha: 'sha-2', + environment: 'production', + createdAt: '2026-06-10T00:00:00.000Z', + result: 'success', + }), + ]); await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( /no evaluable deployment intervals/, diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraChangeFailureRateProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraChangeFailureRateProvider.ts index 539ee18fc06..bfadf307b77 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraChangeFailureRateProvider.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraChangeFailureRateProvider.ts @@ -14,44 +14,38 @@ * limitations under the License. */ +import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; +import { stringifyEntityRef, type Entity } from '@backstage/catalog-model'; import type { LoggerService } from '@backstage/backend-plugin-api'; import type { Config } from '@backstage/config'; -import { stringifyEntityRef, type Entity } from '@backstage/catalog-model'; import { Metric } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; -import { - type ScorecardCollectorsService, - MetricProvider, -} from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; import { DORA_TIME_WINDOW_DAYS } from '../constants'; -import { - deploymentsCollectorInputSchema, - deploymentsCollectorOutputSchema, -} from './schemas/deploymentSchemas'; -import { - incidentsCollectorInputSchema, - incidentsCollectorOutputSchema, -} from './schemas/incidentSchemas'; +import type { DoraDataService } from '../service/DoraDataService'; +import type { DoraSyncService } from '../service/DoraSyncService'; import { DEFAULT_DORA_CHANGE_FAILURE_RATE_THRESHOLDS, type DoraChangeFailureRateConfig, parseDoraChangeFailureRateConfig, } from './DoraConfig'; import { isSuccessfulProductionDeployment } from './utils/deploymentFilterUtils'; -import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; type DoraChangeFailureRateProviderOptions = { - collectorsService: ScorecardCollectorsService; + doraSyncService: DoraSyncService; + doraDataService: DoraDataService; config: DoraChangeFailureRateConfig; logger: LoggerService; }; export class DoraChangeFailureRateProvider implements MetricProvider<'number'> { - private readonly collectorsService: ScorecardCollectorsService; + private readonly doraSyncService: DoraSyncService; + private readonly doraDataService: DoraDataService; private readonly config: DoraChangeFailureRateConfig; private readonly logger: LoggerService; private constructor(options: DoraChangeFailureRateProviderOptions) { - this.collectorsService = options.collectorsService; + this.doraSyncService = options.doraSyncService; + this.doraDataService = options.doraDataService; this.config = options.config; this.logger = options.logger; } @@ -59,12 +53,14 @@ export class DoraChangeFailureRateProvider implements MetricProvider<'number'> { static fromConfig( config: Config, options: { - collectorsService: ScorecardCollectorsService; + doraSyncService: DoraSyncService; + doraDataService: DoraDataService; logger: LoggerService; }, ): DoraChangeFailureRateProvider { return new DoraChangeFailureRateProvider({ - collectorsService: options.collectorsService, + doraSyncService: options.doraSyncService, + doraDataService: options.doraDataService, config: parseDoraChangeFailureRateConfig(config), logger: options.logger, }); @@ -106,30 +102,39 @@ export class DoraChangeFailureRateProvider implements MetricProvider<'number'> { const from = new Date(); from.setDate(to.getDate() - DORA_TIME_WINDOW_DAYS); - const deploymentsCollected = await this.collectorsService.collect< - typeof deploymentsCollectorInputSchema, - typeof deploymentsCollectorOutputSchema - >({ - collectorId: this.config.deploymentsCollector.id, - contract: { - inputSchema: deploymentsCollectorInputSchema, - outputSchema: deploymentsCollectorOutputSchema, - }, - entity, - input: { - ...this.config.deploymentsCollector.input, - from: from.toISOString(), - to: to.toISOString(), - }, - }); - - const successfulProductionDeployments = - deploymentsCollected.deployments.filter(deployment => - isSuccessfulProductionDeployment( - deployment, - this.config.productionEnvironments, - ), - ); + await Promise.all([ + this.doraSyncService.syncDeployments(entity, { + windowFrom: from, + windowTo: to, + collector: this.config.deploymentsCollector, + }), + this.doraSyncService.syncIncidents(entity, { + windowFrom: from, + windowTo: to, + collector: this.config.incidentsCollector, + }), + ]); + + const catalogEntityRef = stringifyEntityRef(entity); + const [deployments, incidents] = await Promise.all([ + this.doraDataService.readDeployments(catalogEntityRef, { + windowFrom: from, + windowTo: to, + collector: this.config.deploymentsCollector, + }), + this.doraDataService.readIncidents(catalogEntityRef, { + windowFrom: from, + windowTo: to, + collector: this.config.incidentsCollector, + }), + ]); + + const successfulProductionDeployments = deployments.filter(deployment => + isSuccessfulProductionDeployment( + deployment, + this.config.productionEnvironments, + ), + ); if (successfulProductionDeployments.length < 2) { throw new Error( @@ -137,23 +142,6 @@ export class DoraChangeFailureRateProvider implements MetricProvider<'number'> { ); } - const incidentsCollected = await this.collectorsService.collect< - typeof incidentsCollectorInputSchema, - typeof incidentsCollectorOutputSchema - >({ - collectorId: this.config.incidentsCollector.id, - contract: { - inputSchema: incidentsCollectorInputSchema, - outputSchema: incidentsCollectorOutputSchema, - }, - entity, - input: { - ...this.config.incidentsCollector.input, - from: from.toISOString(), - to: to.toISOString(), - }, - }); - let deploymentsWithIncidents = 0; let evaluatedDeployments = 0; for ( @@ -164,26 +152,22 @@ export class DoraChangeFailureRateProvider implements MetricProvider<'number'> { const deployment = successfulProductionDeployments[deploymentIndex]; const nextDeployment = successfulProductionDeployments[deploymentIndex + 1]; - const deploymentCreatedAt = new Date(deployment.createdAt).getTime(); - const nextDeploymentCreatedAt = new Date( - nextDeployment.createdAt, - ).getTime(); + const deploymentCreatedAt = deployment.createdAt.getTime(); + const nextDeploymentCreatedAt = nextDeployment.createdAt.getTime(); if (nextDeploymentCreatedAt <= deploymentCreatedAt) { this.logger.warn( `Skipping deployment interval ${deployment.id}..${ nextDeployment.id } for ${stringifyEntityRef( entity, - )} while calculating ${this.getProviderId()}: non-increasing createdAt (deployment=${ - deployment.createdAt - }, nextDeployment=${nextDeployment.createdAt})`, + )} while calculating ${this.getProviderId()}: non-increasing createdAt (deployment=${deployment.createdAt.toISOString()}, nextDeployment=${nextDeployment.createdAt.toISOString()})`, ); continue; } evaluatedDeployments += 1; - const hasIncident = incidentsCollected.incidents.some(incident => { - const incidentCreatedAt = new Date(incident.createdAt).getTime(); + const hasIncident = incidents.some(incident => { + const incidentCreatedAt = incident.createdAt.getTime(); return ( incidentCreatedAt >= deploymentCreatedAt && incidentCreatedAt < nextDeploymentCreatedAt diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraDeploymentFrequencyProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraDeploymentFrequencyProvider.test.ts index 6e22df936d9..8e6ff6aab48 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraDeploymentFrequencyProvider.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraDeploymentFrequencyProvider.test.ts @@ -17,33 +17,27 @@ import { ConfigReader } from '@backstage/config'; import { DoraDeploymentFrequencyProvider } from './DoraDeploymentFrequencyProvider'; import { - buildMockCollectorsService, - buildMockDeploymentsCollector, + buildMockDoraServices, + dbDeployment, mockEntity, } from './__fixtures__'; +import type { DoraDataService } from '../service/DoraDataService'; +import type { DoraSyncService } from '../service/DoraSyncService'; import { DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID } from '../constants'; import { DEFAULT_DORA_DEPLOYMENT_FREQUENCY_THRESHOLDS } from './DoraConfig'; describe('DoraDeploymentFrequencyProvider', () => { - let deploymentsCollector: ReturnType; - let collectorsService: ReturnType< - typeof buildMockCollectorsService - >['collectorsService']; - let collect: ReturnType['collect']; + let doraSyncService: jest.Mocked; + let doraDataService: jest.Mocked; let provider: DoraDeploymentFrequencyProvider; beforeEach(() => { - deploymentsCollector = buildMockDeploymentsCollector({ - deployments: [], - collectorId: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, - }); - ({ collectorsService, collect } = buildMockCollectorsService({ - collectors: [deploymentsCollector], - })); + ({ doraSyncService, doraDataService } = buildMockDoraServices()); provider = DoraDeploymentFrequencyProvider.fromConfig( new ConfigReader({}), { - collectorsService, + doraSyncService, + doraDataService, }, ); }); @@ -61,14 +55,23 @@ describe('DoraDeploymentFrequencyProvider', () => { }); describe('calculateMetrics', () => { - it('should use default collectors when no config', async () => { + it('should sync and read with default collectors when no config', async () => { await provider.calculateMetrics(mockEntity); - expect(collect).toHaveBeenCalledWith( + + expect(doraSyncService.syncDeployments).toHaveBeenCalledWith( + mockEntity, + expect.objectContaining({ + collector: expect.objectContaining({ + id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + input: {}, + }), + }), + ); + expect(doraDataService.readDeployments).toHaveBeenCalledWith( + expect.any(String), expect.objectContaining({ - collectorId: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, - input: expect.objectContaining({ - from: expect.any(String), - to: expect.any(String), + collector: expect.objectContaining({ + id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, }), }), ); @@ -76,16 +79,8 @@ describe('DoraDeploymentFrequencyProvider', () => { it('should use custom collectors and pass custom inputs', async () => { const customCollectorId = 'custom:deployments'; - const customDeploymentsCollector = buildMockDeploymentsCollector({ - deployments: [], - collectorId: customCollectorId, - }); - const { - collectorsService: customCollectorsService, - collect: customCollect, - } = buildMockCollectorsService({ - collectors: [customDeploymentsCollector], - }); + const { doraSyncService: sync, doraDataService: data } = + buildMockDoraServices(); const customProvider = DoraDeploymentFrequencyProvider.fromConfig( new ConfigReader({ @@ -109,63 +104,63 @@ describe('DoraDeploymentFrequencyProvider', () => { }, }), { - collectorsService: customCollectorsService, + doraSyncService: sync, + doraDataService: data, }, ); await customProvider.calculateMetrics(mockEntity); - expect(customCollect).toHaveBeenCalledWith( + expect(sync.syncDeployments).toHaveBeenCalledWith( + mockEntity, expect.objectContaining({ - collectorId: customCollectorId, - input: expect.objectContaining({ - from: expect.any(String), - to: expect.any(String), - artificialLabel: 'frequency-test', + collector: expect.objectContaining({ + id: customCollectorId, + input: expect.objectContaining({ + artificialLabel: 'frequency-test', + }), }), }), ); }); it('should calculate frequency for success result and production environment', async () => { - (deploymentsCollector.collect as jest.Mock).mockResolvedValueOnce({ - deployments: [ - { - id: '100', - commitSha: 'sha-1', - environment: 'production', - createdAt: '2026-06-01T10:00:00.000Z', - result: 'success', - }, - { - id: '101', - commitSha: 'sha-2', - environment: 'production', - createdAt: '2026-06-02T10:00:00.000Z', - result: 'failure', // omitted - }, - { - id: '102', - commitSha: 'sha-3', - environment: 'production', - createdAt: '2026-06-03T10:00:00.000Z', - result: '', // omitted - }, - { - id: '103', - commitSha: 'sha-2', - createdAt: '2026-06-04T10:00:00.000Z', - result: 'success', - }, - { - id: '104', - commitSha: 'sha-4', - environment: 'development', // omitted - createdAt: '2026-06-04T11:00:00.000Z', - result: 'success', - }, - ], - }); + doraDataService.readDeployments.mockResolvedValueOnce([ + dbDeployment({ + id: '100', + commitSha: 'sha-1', + environment: 'production', + createdAt: '2026-06-01T10:00:00.000Z', + result: 'success', + }), + dbDeployment({ + id: '101', + commitSha: 'sha-2', + environment: 'production', + createdAt: '2026-06-02T10:00:00.000Z', + result: 'failure', + }), + dbDeployment({ + id: '102', + commitSha: 'sha-3', + environment: 'production', + createdAt: '2026-06-03T10:00:00.000Z', + result: '', + }), + dbDeployment({ + id: '103', + commitSha: 'sha-2', + createdAt: '2026-06-04T10:00:00.000Z', + result: 'success', + }), + dbDeployment({ + id: '104', + commitSha: 'sha-4', + environment: 'development', + createdAt: '2026-06-04T11:00:00.000Z', + result: 'success', + }), + ]); const results = await provider.calculateMetrics(mockEntity); @@ -173,9 +168,7 @@ describe('DoraDeploymentFrequencyProvider', () => { }); it('returns 0 when no deployments are collected', async () => { - (deploymentsCollector.collect as jest.Mock).mockResolvedValueOnce({ - deployments: [], - }); + doraDataService.readDeployments.mockResolvedValueOnce([]); const results = await provider.calculateMetrics(mockEntity); @@ -183,6 +176,33 @@ describe('DoraDeploymentFrequencyProvider', () => { }); it('should treat configured productionEnvironments as production', async () => { + const { doraSyncService: sync, doraDataService: data } = + buildMockDoraServices({ + deployments: [ + dbDeployment({ + id: '100', + commitSha: 'sha-1', + environment: 'prod', + createdAt: '2026-06-01T10:00:00.000Z', + result: 'success', + }), + dbDeployment({ + id: '101', + commitSha: 'sha-2', + environment: 'live', + createdAt: '2026-06-02T10:00:00.000Z', + result: 'success', + }), + dbDeployment({ + id: '102', + commitSha: 'sha-3', + environment: 'production', + createdAt: '2026-06-03T10:00:00.000Z', + result: 'success', + }), + ], + }); + const customProvider = DoraDeploymentFrequencyProvider.fromConfig( new ConfigReader({ scorecard: { @@ -198,36 +218,11 @@ describe('DoraDeploymentFrequencyProvider', () => { }, }), { - collectorsService, + doraSyncService: sync, + doraDataService: data, }, ); - (deploymentsCollector.collect as jest.Mock).mockResolvedValueOnce({ - deployments: [ - { - id: '100', - commitSha: 'sha-1', - environment: 'prod', - createdAt: '2026-06-01T10:00:00.000Z', - result: 'success', - }, - { - id: '101', - commitSha: 'sha-2', - environment: 'live', - createdAt: '2026-06-02T10:00:00.000Z', - result: 'success', - }, - { - id: '102', - commitSha: 'sha-3', - environment: 'production', - createdAt: '2026-06-03T10:00:00.000Z', - result: 'success', - }, - ], - }); - const results = await customProvider.calculateMetrics(mockEntity); // production is no longer accepted; only prod + live count diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraDeploymentFrequencyProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraDeploymentFrequencyProvider.ts index 353b0d11ed9..139373ee620 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraDeploymentFrequencyProvider.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraDeploymentFrequencyProvider.ts @@ -13,50 +13,50 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; +import { stringifyEntityRef, type Entity } from '@backstage/catalog-model'; import type { Config } from '@backstage/config'; -import type { Entity } from '@backstage/catalog-model'; import { Metric } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; -import { - type ScorecardCollectorsService, - MetricProvider, -} from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; import { DORA_TIME_WINDOW_DAYS } from '../constants'; -import { - deploymentsCollectorInputSchema, - deploymentsCollectorOutputSchema, -} from './schemas/deploymentSchemas'; +import type { DoraDataService } from '../service/DoraDataService'; +import type { DoraSyncService } from '../service/DoraSyncService'; import { DEFAULT_DORA_DEPLOYMENT_FREQUENCY_THRESHOLDS, type DoraDeploymentFrequencyConfig, parseDoraDeploymentFrequencyConfig, } from './DoraConfig'; -import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; import { isSuccessfulProductionDeployment } from './utils/deploymentFilterUtils'; type DoraDeploymentFrequencyProviderOptions = { - collectorsService: ScorecardCollectorsService; + doraSyncService: DoraSyncService; + doraDataService: DoraDataService; config: DoraDeploymentFrequencyConfig; }; export class DoraDeploymentFrequencyProvider implements MetricProvider<'number'> { - private readonly collectorsService: ScorecardCollectorsService; + private readonly doraSyncService: DoraSyncService; + private readonly doraDataService: DoraDataService; private readonly config: DoraDeploymentFrequencyConfig; private constructor(options: DoraDeploymentFrequencyProviderOptions) { - this.collectorsService = options.collectorsService; + this.doraSyncService = options.doraSyncService; + this.doraDataService = options.doraDataService; this.config = options.config; } static fromConfig( config: Config, options: { - collectorsService: ScorecardCollectorsService; + doraSyncService: DoraSyncService; + doraDataService: DoraDataService; }, ): DoraDeploymentFrequencyProvider { return new DoraDeploymentFrequencyProvider({ - collectorsService: options.collectorsService, + doraSyncService: options.doraSyncService, + doraDataService: options.doraDataService, config: parseDoraDeploymentFrequencyConfig(config), }); } @@ -97,35 +97,30 @@ export class DoraDeploymentFrequencyProvider const from = new Date(); from.setDate(to.getDate() - DORA_TIME_WINDOW_DAYS); - const deploymentsCollected = await this.collectorsService.collect< - typeof deploymentsCollectorInputSchema, - typeof deploymentsCollectorOutputSchema - >({ - collectorId: this.config.deploymentsCollector.id, - contract: { - inputSchema: deploymentsCollectorInputSchema, - outputSchema: deploymentsCollectorOutputSchema, - }, - entity, - input: { - ...this.config.deploymentsCollector.input, - from: from.toISOString(), - to: to.toISOString(), - }, + await this.doraSyncService.syncDeployments(entity, { + windowFrom: from, + windowTo: to, + collector: this.config.deploymentsCollector, }); - if (deploymentsCollected.deployments.length === 0) { - results.set(this.getProviderId(), 0); - return results; - } - - const deployments = deploymentsCollected.deployments.filter(deployment => + const deployments = ( + await this.doraDataService.readDeployments(stringifyEntityRef(entity), { + windowFrom: from, + windowTo: to, + collector: this.config.deploymentsCollector, + }) + ).filter(deployment => isSuccessfulProductionDeployment( deployment, this.config.productionEnvironments, ), ); + if (deployments.length === 0) { + results.set(this.getProviderId(), 0); + return results; + } + const deploymentsPerWeek = (deployments.length / DORA_TIME_WINDOW_DAYS) * 7; results.set(this.getProviderId(), Number(deploymentsPerWeek.toFixed(4))); return results; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMeanTimeToRestoreProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMeanTimeToRestoreProvider.test.ts index 70b1d6e0036..9c1cd03d65b 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMeanTimeToRestoreProvider.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMeanTimeToRestoreProvider.test.ts @@ -14,44 +14,35 @@ * limitations under the License. */ -import { mockServices } from '@backstage/backend-test-utils'; import { ConfigReader } from '@backstage/config'; +import { mockServices } from '@backstage/backend-test-utils'; import { DoraMeanTimeToRestoreProvider } from './DoraMeanTimeToRestoreProvider'; -import { - buildMockCollectorsService, - buildMockIncidentsCollector, - mockEntity, -} from './__fixtures__'; +import { buildMockDoraServices, dbIncident, mockEntity } from './__fixtures__'; +import type { DoraDataService } from '../service/DoraDataService'; +import type { DoraSyncService } from '../service/DoraSyncService'; import { DORA_DEFAULT_INCIDENTS_COLLECTOR_ID } from '../constants'; import { DEFAULT_DORA_MEAN_TIME_TO_RESTORE_THRESHOLDS } from './DoraConfig'; -const mockLogger = mockServices.logger.mock(); - describe('DoraMeanTimeToRestoreProvider', () => { - let incidentsCollector: ReturnType; - let collectorsService: ReturnType< - typeof buildMockCollectorsService - >['collectorsService']; - let collect: ReturnType['collect']; + const mockLogger = mockServices.logger.mock(); + let doraSyncService: jest.Mocked; + let doraDataService: jest.Mocked; let provider: DoraMeanTimeToRestoreProvider; beforeEach(() => { - jest.clearAllMocks(); - incidentsCollector = buildMockIncidentsCollector({ + ({ doraSyncService, doraDataService } = buildMockDoraServices({ incidents: [ - { + dbIncident({ id: 'INC-1', createdAt: '2026-06-10T10:00:00.000Z', + updatedAt: '2026-06-10T12:00:00.000Z', resolutionAt: '2026-06-10T12:00:00.000Z', - }, + }), ], - collectorId: DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, - }); - ({ collectorsService, collect } = buildMockCollectorsService({ - collectors: [incidentsCollector], })); provider = DoraMeanTimeToRestoreProvider.fromConfig(new ConfigReader({}), { - collectorsService, + doraSyncService, + doraDataService, logger: mockLogger, }); }); @@ -69,15 +60,23 @@ describe('DoraMeanTimeToRestoreProvider', () => { }); describe('calculateMetrics', () => { - it('should use default collector when no config', async () => { + it('should sync and read with default collector when no config', async () => { await provider.calculateMetrics(mockEntity); - expect(collect).toHaveBeenCalledWith( + expect(doraSyncService.syncIncidents).toHaveBeenCalledWith( + mockEntity, + expect.objectContaining({ + collector: expect.objectContaining({ + id: DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, + input: {}, + }), + }), + ); + expect(doraDataService.readIncidents).toHaveBeenCalledWith( + expect.any(String), expect.objectContaining({ - collectorId: DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, - input: expect.objectContaining({ - from: expect.any(String), - to: expect.any(String), + collector: expect.objectContaining({ + id: DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, }), }), ); @@ -85,22 +84,17 @@ describe('DoraMeanTimeToRestoreProvider', () => { it('should use custom collector and pass custom inputs', async () => { const customIncidentsCollectorId = 'custom:incidents'; - const customIncidentsCollector = buildMockIncidentsCollector({ - incidents: [ - { - id: 'INC-2', - createdAt: '2026-06-10T10:00:00.000Z', - resolutionAt: '2026-06-10T12:00:00.000Z', - }, - ], - collectorId: customIncidentsCollectorId, - }); - const { - collectorsService: customCollectorsService, - collect: customCollect, - } = buildMockCollectorsService({ - collectors: [customIncidentsCollector], - }); + const { doraSyncService: sync, doraDataService: data } = + buildMockDoraServices({ + incidents: [ + dbIncident({ + id: 'INC-2', + createdAt: '2026-06-10T10:00:00.000Z', + updatedAt: '2026-06-10T12:00:00.000Z', + resolutionAt: '2026-06-10T12:00:00.000Z', + }), + ], + }); const customProvider = DoraMeanTimeToRestoreProvider.fromConfig( new ConfigReader({ scorecard: { @@ -123,45 +117,48 @@ describe('DoraMeanTimeToRestoreProvider', () => { }, }), { - collectorsService: customCollectorsService, + doraSyncService: sync, + doraDataService: data, logger: mockLogger, }, ); await customProvider.calculateMetrics(mockEntity); - expect(customCollect).toHaveBeenCalledWith( + expect(sync.syncIncidents).toHaveBeenCalledWith( + mockEntity, expect.objectContaining({ - collectorId: customIncidentsCollectorId, - input: expect.objectContaining({ - from: expect.any(String), - to: expect.any(String), - customIncidentsInputLabel: 'incidents-custom-input', + collector: expect.objectContaining({ + id: customIncidentsCollectorId, + input: expect.objectContaining({ + customIncidentsInputLabel: 'incidents-custom-input', + }), }), }), ); }); it('should calculate mean time to restore in hours', async () => { - jest.mocked(incidentsCollector.collect).mockResolvedValueOnce({ - incidents: [ - { - id: 'INC-1', - createdAt: '2026-06-10T10:00:00.000Z', - resolutionAt: '2026-06-10T11:00:00.000Z', // 1h - }, - { - id: 'INC-2', - createdAt: '2026-06-11T10:00:00.000Z', - resolutionAt: '2026-06-11T12:00:00.000Z', // 2h - }, - { - id: 'INC-3', - createdAt: '2026-06-12T10:00:00.000Z', - resolutionAt: '2026-06-12T16:00:00.000Z', // 6h - }, - ], - }); + doraDataService.readIncidents.mockResolvedValueOnce([ + dbIncident({ + id: 'INC-1', + createdAt: '2026-06-10T10:00:00.000Z', + updatedAt: '2026-06-10T11:00:00.000Z', + resolutionAt: '2026-06-10T11:00:00.000Z', // 1h + }), + dbIncident({ + id: 'INC-2', + createdAt: '2026-06-11T10:00:00.000Z', + updatedAt: '2026-06-11T12:00:00.000Z', + resolutionAt: '2026-06-11T12:00:00.000Z', // 2h + }), + dbIncident({ + id: 'INC-3', + createdAt: '2026-06-12T10:00:00.000Z', + updatedAt: '2026-06-12T16:00:00.000Z', + resolutionAt: '2026-06-12T16:00:00.000Z', // 6h + }), + ]); const results = await provider.calculateMetrics(mockEntity); @@ -169,15 +166,14 @@ describe('DoraMeanTimeToRestoreProvider', () => { }); it('should throw when no resolved incidents are found', async () => { - jest.mocked(incidentsCollector.collect).mockResolvedValueOnce({ - incidents: [ - { - id: 'INC-1', - createdAt: '2026-06-10T10:00:00.000Z', - resolutionAt: null, - }, - ], - }); + doraDataService.readIncidents.mockResolvedValueOnce([ + dbIncident({ + id: 'INC-1', + createdAt: '2026-06-10T10:00:00.000Z', + updatedAt: '2026-06-10T10:00:00.000Z', + resolutionAt: null, + }), + ]); await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( 'Unable to calculate mean time to restore: no resolved incidents with measurable recovery time were found', @@ -185,9 +181,7 @@ describe('DoraMeanTimeToRestoreProvider', () => { }); it('should throw when no incidents are found', async () => { - jest.mocked(incidentsCollector.collect).mockResolvedValueOnce({ - incidents: [], - }); + doraDataService.readIncidents.mockResolvedValueOnce([]); await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( 'Unable to calculate mean time to restore: no resolved incidents with measurable recovery time were found', @@ -195,15 +189,14 @@ describe('DoraMeanTimeToRestoreProvider', () => { }); it('should throw when resolved incidents are invalid and none are measurable', async () => { - jest.mocked(incidentsCollector.collect).mockResolvedValueOnce({ - incidents: [ - { - id: 'INC-1', - createdAt: '2026-06-10T12:00:00.000Z', - resolutionAt: '2026-06-10T10:00:00.000Z', - }, - ], - }); + doraDataService.readIncidents.mockResolvedValueOnce([ + dbIncident({ + id: 'INC-1', + createdAt: '2026-06-10T12:00:00.000Z', + updatedAt: '2026-06-10T12:00:00.000Z', + resolutionAt: '2026-06-10T10:00:00.000Z', + }), + ]); await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( /resolutionAt before createdAt and no measurable recovery times/, @@ -214,20 +207,20 @@ describe('DoraMeanTimeToRestoreProvider', () => { }); it('should skip invalid resolved incidents and calculate mean from the rest', async () => { - jest.mocked(incidentsCollector.collect).mockResolvedValueOnce({ - incidents: [ - { - id: 'INC-1', - createdAt: '2026-06-10T12:00:00.000Z', - resolutionAt: '2026-06-10T10:00:00.000Z', - }, - { - id: 'INC-2', - createdAt: '2026-06-11T10:00:00.000Z', - resolutionAt: '2026-06-11T12:00:00.000Z', // 2h - }, - ], - }); + doraDataService.readIncidents.mockResolvedValueOnce([ + dbIncident({ + id: 'INC-1', + createdAt: '2026-06-10T12:00:00.000Z', + updatedAt: '2026-06-10T12:00:00.000Z', + resolutionAt: '2026-06-10T10:00:00.000Z', + }), + dbIncident({ + id: 'INC-2', + createdAt: '2026-06-11T10:00:00.000Z', + updatedAt: '2026-06-11T12:00:00.000Z', + resolutionAt: '2026-06-11T12:00:00.000Z', // 2h + }), + ]); const results = await provider.calculateMetrics(mockEntity); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMeanTimeToRestoreProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMeanTimeToRestoreProvider.ts index 54844a7868d..9b0619b2589 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMeanTimeToRestoreProvider.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMeanTimeToRestoreProvider.ts @@ -14,40 +14,38 @@ * limitations under the License. */ +import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; +import { stringifyEntityRef, type Entity } from '@backstage/catalog-model'; import type { LoggerService } from '@backstage/backend-plugin-api'; import type { Config } from '@backstage/config'; -import { stringifyEntityRef, type Entity } from '@backstage/catalog-model'; import { Metric } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; -import { - type ScorecardCollectorsService, - MetricProvider, -} from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; import { DORA_TIME_WINDOW_DAYS } from '../constants'; -import { - incidentsCollectorInputSchema, - incidentsCollectorOutputSchema, -} from './schemas/incidentSchemas'; -import { calculateMean } from './utils/calculationUtils'; +import type { DoraDataService } from '../service/DoraDataService'; +import type { DoraSyncService } from '../service/DoraSyncService'; import { DEFAULT_DORA_MEAN_TIME_TO_RESTORE_THRESHOLDS, type DoraMeanTimeToRestoreConfig, parseDoraMeanTimeToRestoreConfig, } from './DoraConfig'; -import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; +import { calculateMean } from './utils/calculationUtils'; type DoraMeanTimeToRestoreProviderOptions = { - collectorsService: ScorecardCollectorsService; + doraSyncService: DoraSyncService; + doraDataService: DoraDataService; config: DoraMeanTimeToRestoreConfig; logger: LoggerService; }; export class DoraMeanTimeToRestoreProvider implements MetricProvider<'number'> { - private readonly collectorsService: ScorecardCollectorsService; + private readonly doraSyncService: DoraSyncService; + private readonly doraDataService: DoraDataService; private readonly config: DoraMeanTimeToRestoreConfig; private readonly logger: LoggerService; private constructor(options: DoraMeanTimeToRestoreProviderOptions) { - this.collectorsService = options.collectorsService; + this.doraSyncService = options.doraSyncService; + this.doraDataService = options.doraDataService; this.config = options.config; this.logger = options.logger; } @@ -55,12 +53,14 @@ export class DoraMeanTimeToRestoreProvider implements MetricProvider<'number'> { static fromConfig( config: Config, options: { - collectorsService: ScorecardCollectorsService; + doraSyncService: DoraSyncService; + doraDataService: DoraDataService; logger: LoggerService; }, ): DoraMeanTimeToRestoreProvider { return new DoraMeanTimeToRestoreProvider({ - collectorsService: options.collectorsService, + doraSyncService: options.doraSyncService, + doraDataService: options.doraDataService, config: parseDoraMeanTimeToRestoreConfig(config), logger: options.logger, }); @@ -102,39 +102,35 @@ export class DoraMeanTimeToRestoreProvider implements MetricProvider<'number'> { const from = new Date(); from.setDate(to.getDate() - DORA_TIME_WINDOW_DAYS); - const incidentsCollected = await this.collectorsService.collect< - typeof incidentsCollectorInputSchema, - typeof incidentsCollectorOutputSchema - >({ - collectorId: this.config.incidentsCollector.id, - contract: { - inputSchema: incidentsCollectorInputSchema, - outputSchema: incidentsCollectorOutputSchema, - }, - entity, - input: { - ...this.config.incidentsCollector.input, - from: from.toISOString(), - to: to.toISOString(), - }, + await this.doraSyncService.syncIncidents(entity, { + windowFrom: from, + windowTo: to, + collector: this.config.incidentsCollector, }); + const incidents = await this.doraDataService.readIncidents( + stringifyEntityRef(entity), + { + windowFrom: from, + windowTo: to, + collector: this.config.incidentsCollector, + }, + ); + const recoveryHours: number[] = []; let invalidResolvedIncidents = 0; - for (const incident of incidentsCollected.incidents) { + for (const incident of incidents) { if (!incident.resolutionAt) { continue; } - const createdAtTimestamp = new Date(incident.createdAt).getTime(); - const resolutionAtTimestamp = new Date(incident.resolutionAt).getTime(); + const createdAtTimestamp = incident.createdAt.getTime(); + const resolutionAtTimestamp = incident.resolutionAt.getTime(); if (resolutionAtTimestamp < createdAtTimestamp) { invalidResolvedIncidents += 1; this.logger.warn( `Skipping incident ${incident.id} for ${stringifyEntityRef( entity, - )} while calculating ${this.getProviderId()}: resolutionAt (${ - incident.resolutionAt - }) is before createdAt (${incident.createdAt})`, + )} while calculating ${this.getProviderId()}: resolutionAt (${incident.resolutionAt.toISOString()}) is before createdAt (${incident.createdAt.toISOString()})`, ); continue; } diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMedianLeadTimeForChangesProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMedianLeadTimeForChangesProvider.test.ts index 94cb118041c..f70c6f62c16 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMedianLeadTimeForChangesProvider.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMedianLeadTimeForChangesProvider.test.ts @@ -14,80 +14,68 @@ * limitations under the License. */ -import { mockServices } from '@backstage/backend-test-utils'; import { ConfigReader } from '@backstage/config'; +import { mockServices } from '@backstage/backend-test-utils'; import { - buildMockDeploymentsCollector, - buildMockDeploymentPullRequestsCollector, - buildMockCollectorsService, + buildMockDoraServices, + dbDeployment, + dbPullRequest, mockEntity, } from './__fixtures__'; +import type { DoraDataService } from '../service/DoraDataService'; +import type { DoraSyncService } from '../service/DoraSyncService'; import { DoraMedianLeadTimeForChangesProvider } from './DoraMedianLeadTimeForChangesProvider'; import { DORA_DEFAULT_DEPLOYMENT_PULL_REQUESTS_COLLECTOR_ID, DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, } from '../constants'; -import { Deployment } from './schemas/deploymentSchemas'; -import { PullRequest } from './schemas/pullRequestSchemas'; import { DEFAULT_DORA_MEDIAN_LEAD_TIME_THRESHOLDS } from './DoraConfig'; -const mockLogger = mockServices.logger.mock(); - describe('DoraMedianLeadTimeForChangesProvider', () => { - const deployments: Deployment[] = [ - { + const mockLogger = mockServices.logger.mock(); + const deployments = [ + dbDeployment({ id: '100', commitSha: 'sha-previous', environment: 'production', createdAt: '2026-06-06T12:00:00.000Z', result: 'success', - }, - { + }), + dbDeployment({ id: '101', commitSha: 'sha-current', environment: 'production', createdAt: '2026-06-08T12:00:00.000Z', result: 'success', - }, + }), ]; - const pullRequests: PullRequest[] = [ - { + const pullRequests = [ + dbPullRequest({ id: '123', firstCommitAt: '2026-06-05T12:00:00.000Z', // 72h from sha-current createdAt - }, - { + deploymentId: '101', + }), + dbPullRequest({ id: '124', firstCommitAt: '2026-06-07T12:00:00.000Z', // 24h from sha-current createdAt - }, + deploymentId: '101', + }), ]; - let deploymentsCollector: ReturnType; - let deploymentPullRequestsCollector: ReturnType< - typeof buildMockDeploymentPullRequestsCollector - >; - let collectorsService: ReturnType< - typeof buildMockCollectorsService - >['collectorsService']; - let collect: ReturnType['collect']; + let doraSyncService: jest.Mocked; + let doraDataService: jest.Mocked; let provider: DoraMedianLeadTimeForChangesProvider; beforeEach(() => { - jest.clearAllMocks(); - deploymentsCollector = buildMockDeploymentsCollector({ + ({ doraSyncService, doraDataService } = buildMockDoraServices({ deployments, - collectorId: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, - }); - deploymentPullRequestsCollector = buildMockDeploymentPullRequestsCollector({ pullRequests, - collectorId: DORA_DEFAULT_DEPLOYMENT_PULL_REQUESTS_COLLECTOR_ID, - }); - ({ collectorsService, collect } = buildMockCollectorsService({ - collectors: [deploymentsCollector, deploymentPullRequestsCollector], })); provider = DoraMedianLeadTimeForChangesProvider.fromConfig( new ConfigReader({}), { - collectorsService, + doraSyncService, + doraDataService, logger: mockLogger, }, ); @@ -106,24 +94,29 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { }); describe('calculateMetrics', () => { - it('should use default collectors when no config', async () => { + it('should sync deployments and pull requests with default collectors', async () => { await provider.calculateMetrics(mockEntity); - expect(collect).toHaveBeenCalledWith( + + expect(doraSyncService.syncDeployments).toHaveBeenCalledWith( + mockEntity, expect.objectContaining({ - collectorId: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, - input: expect.objectContaining({ - from: expect.any(String), - to: expect.any(String), + collector: expect.objectContaining({ + id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + input: {}, }), }), ); - expect(collect).toHaveBeenCalledWith( + expect( + doraSyncService.syncPullRequestsForDeployment, + ).toHaveBeenCalledWith( + mockEntity, expect.objectContaining({ - collectorId: DORA_DEFAULT_DEPLOYMENT_PULL_REQUESTS_COLLECTOR_ID, - input: expect.objectContaining({ - baseCommitSha: 'sha-previous', - headCommitSha: 'sha-current', + collector: expect.objectContaining({ + id: DORA_DEFAULT_DEPLOYMENT_PULL_REQUESTS_COLLECTOR_ID, }), + deploymentId: '101', + baseCommitSha: 'sha-previous', + headCommitSha: 'sha-current', }), ); }); @@ -132,24 +125,11 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { const customDeploymentsCollectorId = 'custom:deployments'; const customDeploymentPullRequestsCollectorId = 'custom:deploymentPullRequests'; - const customDeploymentsCollector = buildMockDeploymentsCollector({ - deployments, - collectorId: customDeploymentsCollectorId, - }); - const customDeploymentPullRequestsCollector = - buildMockDeploymentPullRequestsCollector({ + const { doraSyncService: sync, doraDataService: data } = + buildMockDoraServices({ + deployments, pullRequests, - collectorId: customDeploymentPullRequestsCollectorId, }); - const { - collectorsService: customCollectorsService, - collect: customCollect, - } = buildMockCollectorsService({ - collectors: [ - customDeploymentsCollector, - customDeploymentPullRequestsCollector, - ], - }); const customProvider = DoraMedianLeadTimeForChangesProvider.fromConfig( new ConfigReader({ @@ -181,33 +161,38 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { }, }), { - collectorsService: customCollectorsService, + doraSyncService: sync, + doraDataService: data, logger: mockLogger, }, ); await customProvider.calculateMetrics(mockEntity); - expect(customCollect).toHaveBeenCalledTimes(2); - expect(customCollect).toHaveBeenCalledWith( + expect(sync.syncDeployments).toHaveBeenCalledWith( + mockEntity, expect.objectContaining({ - collectorId: customDeploymentsCollectorId, - input: expect.objectContaining({ - from: expect.any(String), - to: expect.any(String), - artificialDeploymentFlag: true, - customDeploymentsInputLabel: 'deployments-custom-input', + collector: expect.objectContaining({ + id: customDeploymentsCollectorId, + input: expect.objectContaining({ + artificialDeploymentFlag: true, + customDeploymentsInputLabel: 'deployments-custom-input', + }), }), }), ); - expect(customCollect).toHaveBeenCalledWith( + expect(sync.syncPullRequestsForDeployment).toHaveBeenCalledWith( + mockEntity, expect.objectContaining({ - collectorId: customDeploymentPullRequestsCollectorId, - input: expect.objectContaining({ - baseCommitSha: 'sha-previous', - headCommitSha: 'sha-current', - artificialPullRequestsLabel: 'prs-custom-input', + collector: expect.objectContaining({ + id: customDeploymentPullRequestsCollectorId, + input: expect.objectContaining({ + artificialPullRequestsLabel: 'prs-custom-input', + }), }), + deploymentId: '101', + baseCommitSha: 'sha-previous', + headCommitSha: 'sha-current', }), ); }); @@ -219,81 +204,112 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { }); it('should calculate median with multiple pull requests across multiple deployment ranges', async () => { - jest.mocked(deploymentsCollector.collect).mockResolvedValueOnce({ - deployments: [ - { - id: '400', - commitSha: 'sha-1', - environment: 'production', - createdAt: '2026-06-10T00:00:00.000Z', - result: 'success', - }, - { - id: '401', - commitSha: 'sha-2', - environment: 'production', - createdAt: '2026-06-11T00:00:00.000Z', - result: 'success', - }, - { - id: '402', - commitSha: 'sha-3', - environment: 'production', - createdAt: '2026-06-12T00:00:00.000Z', - result: 'success', - }, - ], - }); - jest - .mocked(deploymentPullRequestsCollector.collect) - .mockResolvedValueOnce({ - pullRequests: [ - { id: '501', firstCommitAt: '2026-06-10T18:00:00.000Z' }, // 6h from sha-2 createdAt - { id: '502', firstCommitAt: '2026-06-10T12:00:00.000Z' }, // 12h from sha-2 createdAt - ], - }) - .mockResolvedValueOnce({ - pullRequests: [ - { id: '503', firstCommitAt: '2026-06-11T12:00:00.000Z' }, - ], // 12h from sha-3 createdAt - }); + doraDataService.readDeployments.mockResolvedValueOnce([ + dbDeployment({ + id: '400', + commitSha: 'sha-1', + environment: 'production', + createdAt: '2026-06-10T00:00:00.000Z', + result: 'success', + }), + dbDeployment({ + id: '401', + commitSha: 'sha-2', + environment: 'production', + createdAt: '2026-06-11T00:00:00.000Z', + result: 'success', + }), + dbDeployment({ + id: '402', + commitSha: 'sha-3', + environment: 'production', + createdAt: '2026-06-12T00:00:00.000Z', + result: 'success', + }), + ]); + doraDataService.readPullRequestsForDeployment + .mockResolvedValueOnce([ + dbPullRequest({ + id: '501', + firstCommitAt: '2026-06-10T18:00:00.000Z', // 6h + deploymentId: '401', + }), + dbPullRequest({ + id: '502', + firstCommitAt: '2026-06-10T12:00:00.000Z', // 12h + deploymentId: '401', + }), + ]) + .mockResolvedValueOnce([ + dbPullRequest({ + id: '503', + firstCommitAt: '2026-06-11T12:00:00.000Z', // 12h + deploymentId: '402', + }), + ]); const results = await provider.calculateMetrics(mockEntity); expect(results.get('dora.medianLeadTimeForChanges')).toBe(12); - expect(deploymentPullRequestsCollector.collect).toHaveBeenCalledTimes(2); - expect(deploymentPullRequestsCollector.collect).toHaveBeenNthCalledWith( + expect( + doraSyncService.syncPullRequestsForDeployment, + ).toHaveBeenCalledTimes(2); + expect( + doraSyncService.syncPullRequestsForDeployment, + ).toHaveBeenNthCalledWith( 1, + mockEntity, expect.objectContaining({ - input: expect.objectContaining({ - baseCommitSha: 'sha-1', - headCommitSha: 'sha-2', - }), + deploymentId: '401', + baseCommitSha: 'sha-1', + headCommitSha: 'sha-2', }), ); - expect(deploymentPullRequestsCollector.collect).toHaveBeenNthCalledWith( + expect( + doraSyncService.syncPullRequestsForDeployment, + ).toHaveBeenNthCalledWith( 2, + mockEntity, expect.objectContaining({ - input: expect.objectContaining({ - baseCommitSha: 'sha-2', - headCommitSha: 'sha-3', - }), + deploymentId: '402', + baseCommitSha: 'sha-2', + headCommitSha: 'sha-3', }), ); }); it('should throw when fewer than 2 successful production deployments are found', async () => { - jest.mocked(deploymentsCollector.collect).mockResolvedValueOnce({ - deployments: [], - }); + doraDataService.readDeployments.mockResolvedValueOnce([]); await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( /need at least 2 successful production deployments/, ); - expect(deploymentPullRequestsCollector.collect).not.toHaveBeenCalled(); + expect( + doraSyncService.syncPullRequestsForDeployment, + ).not.toHaveBeenCalled(); }); it('should use configured productionEnvironments when filtering deployments', async () => { + const { doraSyncService: sync, doraDataService: data } = + buildMockDoraServices({ + deployments: [ + dbDeployment({ + id: '400', + commitSha: 'sha-1', + environment: 'production', + createdAt: '2026-06-10T00:00:00.000Z', + result: 'success', + }), + dbDeployment({ + id: '401', + commitSha: 'sha-2', + environment: 'prod', + createdAt: '2026-06-11T00:00:00.000Z', + result: 'success', + }), + ], + }); + const customProvider = DoraMedianLeadTimeForChangesProvider.fromConfig( new ConfigReader({ scorecard: { @@ -309,71 +325,78 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { }, }), { - collectorsService, + doraSyncService: sync, + doraDataService: data, logger: mockLogger, }, ); - jest.mocked(deploymentsCollector.collect).mockResolvedValueOnce({ - deployments: [ - { - id: '400', - commitSha: 'sha-1', - environment: 'production', - createdAt: '2026-06-10T00:00:00.000Z', - result: 'success', - }, - { - id: '401', - commitSha: 'sha-2', - environment: 'prod', - createdAt: '2026-06-11T00:00:00.000Z', - result: 'success', - }, - ], - }); - await expect(customProvider.calculateMetrics(mockEntity)).rejects.toThrow( /need at least 2 successful production deployments.*found 1/, ); }); - it('should skip failed deployment intervals and calculate median from the rest', async () => { - jest.mocked(deploymentsCollector.collect).mockResolvedValueOnce({ - deployments: [ - { - id: '400', - commitSha: 'sha-1', - environment: 'production', - createdAt: '2026-06-10T00:00:00.000Z', - result: 'success', - }, - { - id: '401', - commitSha: 'sha-2', - environment: 'production', - createdAt: '2026-06-11T00:00:00.000Z', - result: 'success', - }, - { - id: '402', - commitSha: 'sha-3', - environment: 'production', - createdAt: '2026-06-12T00:00:00.000Z', - result: 'success', - }, - ], - }); - jest - .mocked(deploymentPullRequestsCollector.collect) - .mockRejectedValueOnce(new Error('GitHub compare failed')) - .mockResolvedValueOnce({ + it('should throw when no pull requests with measurable lead time are found', async () => { + doraDataService.readPullRequestsForDeployment.mockResolvedValue([]); + + await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( + /no pull requests with a measurable lead time/, + ); + }); + + it('should skip deployment intervals when pull request sync fails and warn', async () => { + const { doraSyncService: sync, doraDataService: data } = + buildMockDoraServices({ + deployments: [ + dbDeployment({ + id: '100', + commitSha: 'sha-1', + environment: 'production', + createdAt: '2026-06-10T00:00:00.000Z', + result: 'success', + }), + dbDeployment({ + id: '101', + commitSha: 'sha-2', + environment: 'production', + createdAt: '2026-06-11T00:00:00.000Z', + result: 'success', + }), + dbDeployment({ + id: '102', + commitSha: 'sha-3', + environment: 'production', + createdAt: '2026-06-12T00:00:00.000Z', + result: 'success', + }), + ], pullRequests: [ - { id: '503', firstCommitAt: '2026-06-11T12:00:00.000Z' }, // 12h + dbPullRequest({ + id: '503', + firstCommitAt: '2026-06-11T12:00:00.000Z', // 12h + deploymentId: '102', + }), ], }); + sync.syncPullRequestsForDeployment + .mockRejectedValueOnce(new Error('GitHub compare failed')) + .mockResolvedValueOnce(undefined); + data.readPullRequestsForDeployment.mockResolvedValue([ + dbPullRequest({ + id: '503', + firstCommitAt: '2026-06-11T12:00:00.000Z', + deploymentId: '102', + }), + ]); - const results = await provider.calculateMetrics(mockEntity); + const results = await DoraMedianLeadTimeForChangesProvider.fromConfig( + new ConfigReader({}), + { + doraSyncService: sync, + doraDataService: data, + logger: mockLogger, + }, + ).calculateMetrics(mockEntity); expect(results.get('dora.medianLeadTimeForChanges')).toBe(12); expect(mockLogger.warn).toHaveBeenCalledWith( @@ -387,35 +410,19 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { ); }); - it('should throw when no pull requests with measurable lead time are found', async () => { - jest.mocked(deploymentsCollector.collect).mockResolvedValueOnce({ - deployments, - }); - jest.mocked(deploymentPullRequestsCollector.collect).mockResolvedValue({ - pullRequests: [], - }); - - await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( - /no pull requests with a measurable lead time/, - ); - }); - it('should skip pull requests with negative lead time and warn', async () => { - jest.mocked(deploymentsCollector.collect).mockResolvedValueOnce({ - deployments, - }); - jest.mocked(deploymentPullRequestsCollector.collect).mockResolvedValue({ - pullRequests: [ - { - id: '999', - firstCommitAt: '2026-06-09T12:00:00.000Z', // after sha-current deployment - }, - { - id: '124', - firstCommitAt: '2026-06-07T12:00:00.000Z', // 24h lead time - }, - ], - }); + doraDataService.readPullRequestsForDeployment.mockResolvedValue([ + dbPullRequest({ + id: '999', + firstCommitAt: '2026-06-09T12:00:00.000Z', // after sha-current deployment + deploymentId: '101', + }), + dbPullRequest({ + id: '124', + firstCommitAt: '2026-06-07T12:00:00.000Z', // 24h lead time + deploymentId: '101', + }), + ]); const results = await provider.calculateMetrics(mockEntity); @@ -428,10 +435,10 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { ); }); - it('should throw when all deployment intervals fail to collect pull requests', async () => { - jest - .mocked(deploymentPullRequestsCollector.collect) - .mockRejectedValue(new Error('collector unavailable')); + it('should throw when all deployment intervals fail to sync pull requests', async () => { + doraSyncService.syncPullRequestsForDeployment.mockRejectedValue( + new Error('collector unavailable'), + ); await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( /no pull requests with a measurable lead time/, @@ -442,32 +449,5 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { ), ); }); - - it('should fail when deployments are not sorted ascending by createdAt', async () => { - const unsortedDeployments: Deployment[] = [ - { - id: '200', - commitSha: 'sha-later', - environment: 'production', - createdAt: '2026-06-08T12:00:00.000Z', - result: 'success', - }, - { - id: '201', - commitSha: 'sha-earlier', - environment: 'production', - createdAt: '2026-06-06T12:00:00.000Z', - result: 'success', - }, - ]; - - jest.mocked(deploymentsCollector.collect).mockResolvedValueOnce({ - deployments: unsortedDeployments, - }); - - await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( - 'Deployments must be sorted in ascending order by createdAt', - ); - }); }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMedianLeadTimeForChangesProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMedianLeadTimeForChangesProvider.ts index 6fe2343ec9e..6d578b4070a 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMedianLeadTimeForChangesProvider.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMedianLeadTimeForChangesProvider.ts @@ -14,34 +14,26 @@ * limitations under the License. */ +import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; +import { stringifyEntityRef, type Entity } from '@backstage/catalog-model'; import type { LoggerService } from '@backstage/backend-plugin-api'; import type { Config } from '@backstage/config'; -import { stringifyEntityRef, type Entity } from '@backstage/catalog-model'; import { Metric } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; -import { - type ScorecardCollectorsService, - MetricProvider, -} from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; import { DORA_TIME_WINDOW_DAYS } from '../constants'; -import { - deploymentPullRequestsCollectorInputSchema, - deploymentPullRequestsCollectorOutputSchema, -} from './schemas/pullRequestSchemas'; -import { - deploymentsCollectorInputSchema, - deploymentsCollectorOutputSchema, -} from './schemas/deploymentSchemas'; -import { calculateMedian } from './utils/calculationUtils'; +import type { DoraDataService } from '../service/DoraDataService'; +import type { DoraSyncService } from '../service/DoraSyncService'; import { DEFAULT_DORA_MEDIAN_LEAD_TIME_THRESHOLDS, - type DoraMedianLeadTimeForChangesConfig, parseDoraMedianLeadTimeForChangesConfig, + type DoraMedianLeadTimeForChangesConfig, } from './DoraConfig'; -import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; +import { calculateMedian } from './utils/calculationUtils'; import { isSuccessfulProductionDeployment } from './utils/deploymentFilterUtils'; type DoraMedianLeadTimeForChangesProviderOptions = { - collectorsService: ScorecardCollectorsService; + doraSyncService: DoraSyncService; + doraDataService: DoraDataService; config: DoraMedianLeadTimeForChangesConfig; logger: LoggerService; }; @@ -49,12 +41,14 @@ type DoraMedianLeadTimeForChangesProviderOptions = { export class DoraMedianLeadTimeForChangesProvider implements MetricProvider<'number'> { - private readonly collectorsService: ScorecardCollectorsService; + private readonly doraSyncService: DoraSyncService; + private readonly doraDataService: DoraDataService; private readonly config: DoraMedianLeadTimeForChangesConfig; private readonly logger: LoggerService; private constructor(options: DoraMedianLeadTimeForChangesProviderOptions) { - this.collectorsService = options.collectorsService; + this.doraSyncService = options.doraSyncService; + this.doraDataService = options.doraDataService; this.config = options.config; this.logger = options.logger; } @@ -62,12 +56,14 @@ export class DoraMedianLeadTimeForChangesProvider static fromConfig( config: Config, options: { - collectorsService: ScorecardCollectorsService; + doraSyncService: DoraSyncService; + doraDataService: DoraDataService; logger: LoggerService; }, ): DoraMedianLeadTimeForChangesProvider { return new DoraMedianLeadTimeForChangesProvider({ - collectorsService: options.collectorsService, + doraSyncService: options.doraSyncService, + doraDataService: options.doraDataService, config: parseDoraMedianLeadTimeForChangesConfig(config), logger: options.logger, }); @@ -109,25 +105,22 @@ export class DoraMedianLeadTimeForChangesProvider const from = new Date(); from.setDate(to.getDate() - DORA_TIME_WINDOW_DAYS); - const deploymentsCollected = await this.collectorsService.collect< - typeof deploymentsCollectorInputSchema, - typeof deploymentsCollectorOutputSchema - >({ - collectorId: this.config.deploymentsCollector.id, - contract: { - inputSchema: deploymentsCollectorInputSchema, - outputSchema: deploymentsCollectorOutputSchema, - }, - entity, - input: { - ...this.config.deploymentsCollector.input, - from: from.toISOString(), - to: to.toISOString(), - }, + await this.doraSyncService.syncDeployments(entity, { + windowFrom: from, + windowTo: to, + collector: this.config.deploymentsCollector, }); + const catalogEntityRef = stringifyEntityRef(entity); + // Deployments are expected to be returned sorted ascending by createdAt. - const deployments = deploymentsCollected.deployments.filter(deployment => + const deployments = ( + await this.doraDataService.readDeployments(catalogEntityRef, { + windowFrom: from, + windowTo: to, + collector: this.config.deploymentsCollector, + }) + ).filter(deployment => isSuccessfulProductionDeployment( deployment, this.config.productionEnvironments, @@ -149,23 +142,12 @@ export class DoraMedianLeadTimeForChangesProvider const previousDeployment = deployments[deploymentIndex - 1]; const deployment = deployments[deploymentIndex]; - let pullRequestsCollected; try { - pullRequestsCollected = await this.collectorsService.collect< - typeof deploymentPullRequestsCollectorInputSchema, - typeof deploymentPullRequestsCollectorOutputSchema - >({ - collectorId: this.config.deploymentPullRequestsCollector.id, - contract: { - inputSchema: deploymentPullRequestsCollectorInputSchema, - outputSchema: deploymentPullRequestsCollectorOutputSchema, - }, - entity, - input: { - ...this.config.deploymentPullRequestsCollector.input, - baseCommitSha: previousDeployment.commitSha, - headCommitSha: deployment.commitSha, - }, + await this.doraSyncService.syncPullRequestsForDeployment(entity, { + collector: this.config.deploymentPullRequestsCollector, + deploymentId: deployment.id, + baseCommitSha: previousDeployment.commitSha, + headCommitSha: deployment.commitSha, }); } catch (error) { this.logger.warn( @@ -180,20 +162,25 @@ export class DoraMedianLeadTimeForChangesProvider continue; } - const deployedAtTimestamp = new Date(deployment.createdAt).getTime(); - for (const pullRequest of pullRequestsCollected.pullRequests) { - const firstCommitAtTimestamp = new Date( - pullRequest.firstCommitAt, - ).getTime(); + const pullRequests = + await this.doraDataService.readPullRequestsForDeployment( + catalogEntityRef, + { + collector: this.config.deploymentPullRequestsCollector, + deploymentId: deployment.id, + }, + ); + + const deployedAtTimestamp = deployment.createdAt.getTime(); + for (const pullRequest of pullRequests) { + const firstCommitAtTimestamp = pullRequest.firstCommitAt.getTime(); if (deployedAtTimestamp < firstCommitAtTimestamp) { this.logger.warn( `Skipping pull request ${pullRequest.id} for deployment ${ deployment.id } (${stringifyEntityRef( entity, - )}) while calculating ${this.getProviderId()}: negative lead time (deployedAt=${ - deployment.createdAt - }, firstCommitAt=${pullRequest.firstCommitAt})`, + )}) while calculating ${this.getProviderId()}: negative lead time (deployedAt=${deployment.createdAt.toISOString()}, firstCommitAt=${pullRequest.firstCommitAt.toISOString()})`, ); continue; } diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/index.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/index.ts index 09ec3d30c5c..0ed8e7e4605 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/index.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/index.ts @@ -16,4 +16,5 @@ export * from './mockCollectors'; export * from './mockCollectorsService'; +export * from './mockDoraDataService'; export * from './mockEntity'; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockDoraDataService.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockDoraDataService.ts new file mode 100644 index 00000000000..899fac96d22 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockDoraDataService.ts @@ -0,0 +1,113 @@ +/* + * 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. + */ + +import { asDate } from '../../database/mappers'; +import type { + DbDoraDeployment, + DbDoraIncident, + DbDoraPullRequest, +} from '../../database/types'; +import type { DoraDataService } from '../../service/DoraDataService'; +import type { DoraSyncService } from '../../service/DoraSyncService'; + +const DEFAULT_ENTITY_REF = 'component:default/mock'; + +export function dbDeployment(partial: { + id: string; + commitSha: string; + environment?: string | null; + createdAt: string | Date; + result: string; + catalogEntityRef?: string; + collectorId?: string; + originalDeploymentId?: string; +}): DbDoraDeployment { + return { + id: partial.id, + catalogEntityRef: partial.catalogEntityRef ?? DEFAULT_ENTITY_REF, + collectorId: partial.collectorId ?? 'github:deployments', + originalDeploymentId: partial.originalDeploymentId ?? partial.id, + commitSha: partial.commitSha, + environment: partial.environment ?? null, + createdAt: asDate(partial.createdAt), + result: partial.result, + }; +} + +export function dbIncident(partial: { + id: string; + createdAt: string | Date; + updatedAt: string | Date; + resolutionAt?: string | Date | null; + catalogEntityRef?: string; + collectorId?: string; + originalIncidentId?: string; +}): DbDoraIncident { + return { + id: partial.id, + catalogEntityRef: partial.catalogEntityRef ?? DEFAULT_ENTITY_REF, + collectorId: partial.collectorId ?? 'jira:incidents', + originalIncidentId: partial.originalIncidentId ?? partial.id, + createdAt: asDate(partial.createdAt), + updatedAt: asDate(partial.updatedAt), + resolutionAt: + partial.resolutionAt === undefined || partial.resolutionAt === null + ? null + : asDate(partial.resolutionAt), + }; +} + +export function dbPullRequest(partial: { + id: string; + firstCommitAt: string | Date; + deploymentId?: string | null; + catalogEntityRef?: string; + collectorId?: string; + originalPrId?: string; +}): DbDoraPullRequest { + return { + id: partial.id, + catalogEntityRef: partial.catalogEntityRef ?? DEFAULT_ENTITY_REF, + collectorId: partial.collectorId ?? 'github:deploymentPullRequests', + originalPrId: partial.originalPrId ?? partial.id, + firstCommitAt: asDate(partial.firstCommitAt), + deploymentId: partial.deploymentId ?? null, + }; +} + +export function buildMockDoraServices(options?: { + deployments?: DbDoraDeployment[]; + incidents?: DbDoraIncident[]; + pullRequests?: DbDoraPullRequest[]; +}): { + doraSyncService: jest.Mocked; + doraDataService: jest.Mocked; +} { + return { + doraSyncService: { + syncDeployments: jest.fn().mockResolvedValue(undefined), + syncIncidents: jest.fn().mockResolvedValue(undefined), + syncPullRequestsForDeployment: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked, + doraDataService: { + readDeployments: jest.fn().mockResolvedValue(options?.deployments ?? []), + readIncidents: jest.fn().mockResolvedValue(options?.incidents ?? []), + readPullRequestsForDeployment: jest + .fn() + .mockResolvedValue(options?.pullRequests ?? []), + } as unknown as jest.Mocked, + }; +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/schemas/deploymentSchemas.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/schemas/deploymentSchemas.test.ts new file mode 100644 index 00000000000..a17b0033434 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/schemas/deploymentSchemas.test.ts @@ -0,0 +1,72 @@ +/* + * 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. + */ + +import { deploymentsCollectorOutputSchema } from './deploymentSchemas'; + +describe('deploymentsCollectorOutputSchema', () => { + it('accepts deployments sorted ascending by createdAt', () => { + const result = deploymentsCollectorOutputSchema.safeParse({ + deployments: [ + { + id: '100', + commitSha: 'sha-1', + createdAt: '2026-06-06T12:00:00.000Z', + result: 'success', + }, + { + id: '101', + commitSha: 'sha-2', + createdAt: '2026-06-08T12:00:00.000Z', + result: 'success', + }, + ], + }); + + expect(result.success).toBe(true); + }); + + it('rejects deployments that are not sorted ascending by createdAt', () => { + const result = deploymentsCollectorOutputSchema.safeParse({ + deployments: [ + { + id: '200', + commitSha: 'sha-later', + createdAt: '2026-06-08T12:00:00.000Z', + result: 'success', + }, + { + id: '201', + commitSha: 'sha-earlier', + createdAt: '2026-06-06T12:00:00.000Z', + result: 'success', + }, + ], + }); + + expect(result).toMatchObject({ + success: false, + error: { + issues: expect.arrayContaining([ + expect.objectContaining({ + message: + 'Deployments must be sorted in ascending order by createdAt', + path: ['deployments', 1, 'createdAt'], + }), + ]), + }, + }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/schemas/incidentSchemas.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/schemas/incidentSchemas.ts index 0ef1ec7c6dd..7b2591edb2e 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/schemas/incidentSchemas.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/schemas/incidentSchemas.ts @@ -20,6 +20,7 @@ export const incidentsCollectorInputSchema = z .object({ from: z.string().datetime(), to: z.string().datetime(), + updatedSince: z.string().datetime(), }) .passthrough(); @@ -27,6 +28,7 @@ const incidentSchema = z .object({ id: z.string(), createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), resolutionAt: z.string().datetime().nullable(), }) .passthrough(); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/utils/deploymentFilterUtils.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/utils/deploymentFilterUtils.test.ts index 4d5d5acc538..33c86e7ead9 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/utils/deploymentFilterUtils.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/utils/deploymentFilterUtils.test.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { Deployment } from '../schemas/deploymentSchemas'; import { isProductionEnvironment, isSuccessfulProductionDeployment, @@ -24,6 +23,7 @@ describe('deploymentFilterUtils', () => { describe('isProductionEnvironment', () => { it('treats missing environment as production', () => { expect(isProductionEnvironment(undefined, ['production'])).toBe(true); + expect(isProductionEnvironment(null, ['production'])).toBe(true); }); it('matches any configured environment name case-insensitively', () => { @@ -40,19 +40,19 @@ describe('deploymentFilterUtils', () => { it('requires success and a production environment', () => { expect( isSuccessfulProductionDeployment( - { result: 'success', environment: 'production' } as Deployment, + { result: 'success', environment: 'production' }, ['production'], ), ).toBe(true); expect( isSuccessfulProductionDeployment( - { result: 'failure', environment: 'production' } as Deployment, + { result: 'failure', environment: 'production' }, ['production'], ), ).toBe(false); expect( isSuccessfulProductionDeployment( - { result: 'success', environment: 'development' } as Deployment, + { result: 'success', environment: 'development' }, ['production'], ), ).toBe(false); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/utils/deploymentFilterUtils.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/utils/deploymentFilterUtils.ts index f4608d86b4b..128eb219bd4 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/utils/deploymentFilterUtils.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/utils/deploymentFilterUtils.ts @@ -14,14 +14,12 @@ * limitations under the License. */ -import type { Deployment } from '../schemas/deploymentSchemas'; - /** * Missing/unknown environment is treated as production. Named environments must * match one of the configured production environment names (case-insensitive). */ export function isProductionEnvironment( - environment: string | undefined, + environment: string | null | undefined, productionEnvironments: string[], ): boolean { if (!environment) { @@ -35,7 +33,7 @@ export function isProductionEnvironment( } export function isSuccessfulProductionDeployment( - deployment: Deployment, + deployment: { result: string; environment?: string | null }, productionEnvironments: string[], ): boolean { if (deployment.result !== 'success') { diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts index 82679956db5..8aeb3420f99 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts @@ -22,10 +22,15 @@ import { scorecardMetricsExtensionPoint, } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; import { migrate } from './database/migration'; +import { DatabaseDoraDeployments } from './database/DatabaseDoraDeployments'; +import { DatabaseDoraIncidents } from './database/DatabaseDoraIncidents'; +import { DatabaseDoraPullRequests } from './database/DatabaseDoraPullRequests'; import { DoraChangeFailureRateProvider } from './metricProviders/DoraChangeFailureRateProvider'; import { DoraDeploymentFrequencyProvider } from './metricProviders/DoraDeploymentFrequencyProvider'; import { DoraMedianLeadTimeForChangesProvider } from './metricProviders/DoraMedianLeadTimeForChangesProvider'; import { DoraMeanTimeToRestoreProvider } from './metricProviders/DoraMeanTimeToRestoreProvider'; +import { DefaultDoraDataService } from './service/DoraDataService'; +import { DefaultDoraSyncService } from './service/DoraSyncService'; export const scorecardModuleDora = createBackendModule({ pluginId: 'scorecard', @@ -36,26 +41,46 @@ export const scorecardModuleDora = createBackendModule({ collectorsService: scorecardCollectorsServiceRef, config: coreServices.rootConfig, database: coreServices.database, - logger: coreServices.logger, metrics: scorecardMetricsExtensionPoint, }, - async init({ collectorsService, config, database, logger, metrics }) { + async init({ collectorsService, config, database, metrics }) { await migrate(database); + const dbClient = await database.getClient(); + const deploymentsDb = new DatabaseDoraDeployments(dbClient); + const incidentsDb = new DatabaseDoraIncidents(dbClient); + const pullRequestsDb = new DatabaseDoraPullRequests(dbClient); + + const doraSyncService = new DefaultDoraSyncService( + collectorsService, + deploymentsDb, + incidentsDb, + pullRequestsDb, + ); + const doraDataService = new DefaultDoraDataService( + deploymentsDb, + incidentsDb, + pullRequestsDb, + ); + metrics.addMetricProvider( DoraDeploymentFrequencyProvider.fromConfig(config, { - collectorsService, + doraSyncService, + doraDataService, }), DoraMedianLeadTimeForChangesProvider.fromConfig(config, { - collectorsService, + doraSyncService, + doraDataService, logger, }), DoraMeanTimeToRestoreProvider.fromConfig(config, { - collectorsService, + doraSyncService, + doraDataService, logger, }), DoraChangeFailureRateProvider.fromConfig(config, { - collectorsService, + doraSyncService, + doraDataService, logger, }), ); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraDataService.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraDataService.test.ts new file mode 100644 index 00000000000..4ceea6c76bc --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraDataService.test.ts @@ -0,0 +1,187 @@ +/* + * 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. + */ + +import { + type TestDatabaseId, + TestDatabases, +} from '@backstage/backend-test-utils'; +import { createTestDatabase } from '../database/__fixtures__'; +import { DefaultDoraDataService } from './DoraDataService'; + +jest.setTimeout(60000); + +describe('DefaultDoraDataService', () => { + const databases = TestDatabases.create({ + ids: ['POSTGRES_15', 'SQLITE_3'], + }); + + async function createService(databaseId: TestDatabaseId) { + const { deployments, incidents, pullRequests } = await createTestDatabase( + await databases.init(databaseId), + ); + + return { + deploymentsDb: deployments, + incidentsDb: incidents, + pullRequestsDb: pullRequests, + dataService: new DefaultDoraDataService( + deployments, + incidents, + pullRequests, + ), + }; + } + + describe('readDeployments', () => { + it.each(databases.eachSupportedId())( + 'returns persisted deployment rows - %p', + async databaseId => { + const { deploymentsDb, dataService } = await createService(databaseId); + const entityRef = 'component:default/service-a'; + const collectorId = 'github:deployments'; + const deploymentId = 'dep-1'; + + await deploymentsDb.upsert([ + { + catalogEntityRef: entityRef, + collectorId, + originalDeploymentId: deploymentId, + commitSha: 'sha-1', + environment: 'production', + createdAt: new Date('2026-06-10T10:00:00.000Z'), + result: 'success', + }, + ]); + + await expect( + dataService.readDeployments(entityRef, { + windowFrom: new Date('2026-06-01T00:00:00.000Z'), + windowTo: new Date('2026-06-30T00:00:00.000Z'), + collector: { id: collectorId }, + }), + ).resolves.toEqual([ + { + id: expect.any(String), + catalogEntityRef: entityRef, + collectorId, + originalDeploymentId: deploymentId, + commitSha: 'sha-1', + environment: 'production', + createdAt: new Date('2026-06-10T10:00:00.000Z'), + result: 'success', + }, + ]); + }, + ); + }); + + describe('readIncidents', () => { + it.each(databases.eachSupportedId())( + 'returns persisted incident rows - %p', + async databaseId => { + const { incidentsDb, dataService } = await createService(databaseId); + const entityRef = 'component:default/service-a'; + const collectorId = 'jira:incidents'; + + await incidentsDb.upsert([ + { + catalogEntityRef: entityRef, + collectorId, + originalIncidentId: 'INC-1', + createdAt: new Date('2026-06-11T10:00:00.000Z'), + updatedAt: new Date('2026-06-11T12:00:00.000Z'), + resolutionAt: new Date('2026-06-11T12:00:00.000Z'), + }, + ]); + + await expect( + dataService.readIncidents(entityRef, { + windowFrom: new Date('2026-06-01T00:00:00.000Z'), + windowTo: new Date('2026-06-30T00:00:00.000Z'), + collector: { id: collectorId }, + }), + ).resolves.toEqual([ + { + id: expect.any(String), + catalogEntityRef: entityRef, + collectorId, + originalIncidentId: 'INC-1', + createdAt: new Date('2026-06-11T10:00:00.000Z'), + updatedAt: new Date('2026-06-11T12:00:00.000Z'), + resolutionAt: new Date('2026-06-11T12:00:00.000Z'), + }, + ]); + }, + ); + }); + + describe('readPullRequestsForDeployment', () => { + it.each(databases.eachSupportedId())( + 'returns persisted pull request rows for a deployment row id - %p', + async databaseId => { + const { deploymentsDb, pullRequestsDb, dataService } = + await createService(databaseId); + const entityRef = 'component:default/service-a'; + const deploymentsCollectorId = 'github:deployments'; + const prCollectorId = 'github:deploymentPullRequests'; + + await deploymentsDb.upsert([ + { + catalogEntityRef: entityRef, + collectorId: deploymentsCollectorId, + originalDeploymentId: 'dep-1', + commitSha: 'sha-1', + environment: 'production', + createdAt: new Date('2026-06-10T10:00:00.000Z'), + result: 'success', + }, + ]); + const [deployment] = await deploymentsDb.readByEntityCollectorAndWindow( + entityRef, + deploymentsCollectorId, + new Date('2026-06-01T00:00:00.000Z'), + new Date('2026-06-30T00:00:00.000Z'), + ); + + await pullRequestsDb.upsert([ + { + catalogEntityRef: entityRef, + collectorId: prCollectorId, + originalPrId: 'pr-1', + firstCommitAt: new Date('2026-06-09T10:00:00.000Z'), + deploymentId: deployment.id, + }, + ]); + + await expect( + dataService.readPullRequestsForDeployment(entityRef, { + collector: { id: prCollectorId }, + deploymentId: deployment.id, + }), + ).resolves.toEqual([ + { + id: expect.any(String), + catalogEntityRef: entityRef, + collectorId: prCollectorId, + originalPrId: 'pr-1', + firstCommitAt: new Date('2026-06-09T10:00:00.000Z'), + deploymentId: deployment.id, + }, + ]); + }, + ); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraDataService.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraDataService.ts new file mode 100644 index 00000000000..0b91117da90 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraDataService.ts @@ -0,0 +1,90 @@ +/* + * 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. + */ + +import type { DoraDeploymentsStore } from '../database/DatabaseDoraDeployments'; +import type { DoraIncidentsStore } from '../database/DatabaseDoraIncidents'; +import type { DoraPullRequestsStore } from '../database/DatabaseDoraPullRequests'; +import type { + DbDoraDeployment, + DbDoraIncident, + DbDoraPullRequest, +} from '../database/types'; +import type { CollectorCallOptions, WindowOptions } from './types'; + +/** + * Reads persisted DORA data for metric calculation. + */ +export interface DoraDataService { + readDeployments( + catalogEntityRef: string, + options: WindowOptions & CollectorCallOptions, + ): Promise; + readIncidents( + catalogEntityRef: string, + options: WindowOptions & CollectorCallOptions, + ): Promise; + readPullRequestsForDeployment( + catalogEntityRef: string, + options: CollectorCallOptions & { + deploymentId: string; + }, + ): Promise; +} + +export class DefaultDoraDataService implements DoraDataService { + constructor( + private readonly deploymentsDb: DoraDeploymentsStore, + private readonly incidentsDb: DoraIncidentsStore, + private readonly pullRequestsDb: DoraPullRequestsStore, + ) {} + + async readDeployments( + catalogEntityRef: string, + options: WindowOptions & CollectorCallOptions, + ): Promise { + return this.deploymentsDb.readByEntityCollectorAndWindow( + catalogEntityRef, + options.collector.id, + options.windowFrom, + options.windowTo, + ); + } + + async readIncidents( + catalogEntityRef: string, + options: WindowOptions & CollectorCallOptions, + ): Promise { + return this.incidentsDb.readByEntityCollectorAndWindow( + catalogEntityRef, + options.collector.id, + options.windowFrom, + options.windowTo, + ); + } + + async readPullRequestsForDeployment( + catalogEntityRef: string, + options: CollectorCallOptions & { + deploymentId: string; + }, + ): Promise { + return this.pullRequestsDb.readByEntityCollectorAndDeployment( + catalogEntityRef, + options.collector.id, + options.deploymentId, + ); + } +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.test.ts new file mode 100644 index 00000000000..8f7238ce5eb --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.test.ts @@ -0,0 +1,214 @@ +/* + * 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. + */ + +import { stringifyEntityRef } from '@backstage/catalog-model'; +import { TestDatabases } from '@backstage/backend-test-utils'; +import { + buildMockCollectorsService, + buildMockDeploymentsCollector, + buildMockIncidentsCollector, + mockEntity, +} from '../metricProviders/__fixtures__'; +import { createTestDatabase } from '../database/__fixtures__'; +import { DefaultDoraDataService } from './DoraDataService'; +import { DefaultDoraSyncService } from './DoraSyncService'; +import { + DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, +} from '../constants'; + +jest.setTimeout(60000); + +describe('DefaultDoraSyncService', () => { + const databases = TestDatabases.create({ + ids: ['POSTGRES_15', 'SQLITE_3'], + }); + + it.each(databases.eachSupportedId())( + 'syncs deployments created after the last watermark - %p', + async databaseId => { + const { deployments, incidents, pullRequests } = await createTestDatabase( + await databases.init(databaseId), + ); + + const deploymentsCollector = buildMockDeploymentsCollector({ + deployments: [ + { + id: '100', + commitSha: 'sha-1', + environment: 'production', + createdAt: '2026-06-10T00:00:00.000Z', + result: 'success', + }, + ], + collectorId: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + }); + const { collectorsService, collect } = buildMockCollectorsService({ + collectors: [deploymentsCollector], + }); + + const syncService = new DefaultDoraSyncService( + collectorsService, + deployments, + incidents, + pullRequests, + ); + const dataService = new DefaultDoraDataService( + deployments, + incidents, + pullRequests, + ); + + const windowFrom = new Date('2026-06-01T00:00:00.000Z'); + const windowTo = new Date('2026-06-30T00:00:00.000Z'); + const catalogEntityRef = stringifyEntityRef(mockEntity); + + await syncService.syncDeployments(mockEntity, { + windowFrom, + windowTo, + collector: { + id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + input: {}, + }, + }); + + const first = await dataService.readDeployments(catalogEntityRef, { + windowFrom, + windowTo, + collector: { id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID }, + }); + + expect(first).toHaveLength(1); + expect(collect).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + from: windowFrom.toISOString(), + to: windowTo.toISOString(), + }), + }), + ); + + jest.mocked(deploymentsCollector.collect).mockResolvedValueOnce({ + deployments: [ + { + id: '101', + commitSha: 'sha-2', + environment: 'production', + createdAt: '2026-06-15T00:00:00.000Z', + result: 'success', + }, + ], + }); + + await syncService.syncDeployments(mockEntity, { + windowFrom, + windowTo, + collector: { + id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + input: {}, + }, + }); + + const second = await dataService.readDeployments(catalogEntityRef, { + windowFrom, + windowTo, + collector: { id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID }, + }); + + expect(second).toHaveLength(2); + expect(collect).toHaveBeenLastCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + from: '2026-06-10T00:00:00.000Z', // uses latest createdAt watermark + to: windowTo.toISOString(), + }), + }), + ); + }, + ); + + it.each(databases.eachSupportedId())( + 'syncs incidents with created after window start and updated since the last watermark - %p', + async databaseId => { + const { deployments, incidents, pullRequests } = await createTestDatabase( + await databases.init(databaseId), + ); + + const incidentsCollector = buildMockIncidentsCollector({ + incidents: [ + { + id: 'INC-1', + createdAt: '2026-06-10T00:00:00.000Z', + updatedAt: '2026-06-11T00:00:00.000Z', + resolutionAt: '2026-06-11T00:00:00.000Z', + }, + ], + collectorId: DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, + }); + const { collectorsService, collect } = buildMockCollectorsService({ + collectors: [incidentsCollector], + }); + + const syncService = new DefaultDoraSyncService( + collectorsService, + deployments, + incidents, + pullRequests, + ); + + const windowFrom = new Date('2026-06-01T00:00:00.000Z'); + const windowTo = new Date('2026-06-30T00:00:00.000Z'); + + await syncService.syncIncidents(mockEntity, { + windowFrom, + windowTo, + collector: { + id: DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, + input: {}, + }, + }); + + expect(collect).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + from: windowFrom.toISOString(), + to: windowTo.toISOString(), + updatedSince: windowFrom.toISOString(), + }), + }), + ); + + await syncService.syncIncidents(mockEntity, { + windowFrom, + windowTo, + collector: { + id: DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, + input: {}, + }, + }); + + expect(collect).toHaveBeenLastCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + from: windowFrom.toISOString(), + to: windowTo.toISOString(), + updatedSince: '2026-06-11T00:00:00.000Z', // uses latest updatedAt watermark + }), + }), + ); + }, + ); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.ts new file mode 100644 index 00000000000..b9ff3bc5d49 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.ts @@ -0,0 +1,215 @@ +/* + * 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. + */ + +import { stringifyEntityRef, type Entity } from '@backstage/catalog-model'; +import type { ScorecardCollectorsService } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import type { DoraDeploymentsStore } from '../database/DatabaseDoraDeployments'; +import type { DoraIncidentsStore } from '../database/DatabaseDoraIncidents'; +import type { DoraPullRequestsStore } from '../database/DatabaseDoraPullRequests'; +import { + deploymentsCollectorInputSchema, + deploymentsCollectorOutputSchema, +} from '../metricProviders/schemas/deploymentSchemas'; +import { + incidentsCollectorInputSchema, + incidentsCollectorOutputSchema, +} from '../metricProviders/schemas/incidentSchemas'; +import { + deploymentPullRequestsCollectorInputSchema, + deploymentPullRequestsCollectorOutputSchema, +} from '../metricProviders/schemas/pullRequestSchemas'; +import type { WindowOptions, CollectorCallOptions } from './types'; +import { laterOf } from './syncUtils'; + +/** + * Collects new DORA source data via collectors and persists it. + */ +export interface DoraSyncService { + syncDeployments( + entity: Entity, + options: WindowOptions & CollectorCallOptions, + ): Promise; + syncIncidents( + entity: Entity, + options: WindowOptions & CollectorCallOptions, + ): Promise; + syncPullRequestsForDeployment( + entity: Entity, + options: CollectorCallOptions & { + deploymentId: string; + baseCommitSha: string; + headCommitSha: string; + }, + ): Promise; +} + +export class DefaultDoraSyncService implements DoraSyncService { + constructor( + private readonly collectorsService: ScorecardCollectorsService, + private readonly deploymentsDb: DoraDeploymentsStore, + private readonly incidentsDb: DoraIncidentsStore, + private readonly pullRequestsDb: DoraPullRequestsStore, + ) {} + + /** + * Retrieves and persists deployments + * that were created after the last successfully synced deployment's createdAt + * for a given entity and collector. + */ + async syncDeployments( + entity: Entity, + options: WindowOptions & CollectorCallOptions, + ): Promise { + const catalogEntityRef = stringifyEntityRef(entity); + const collectorId = options.collector.id; + const syncWatermark = await this.deploymentsDb.getLatestCreatedAt( + catalogEntityRef, + collectorId, + ); + const syncFrom = laterOf(options.windowFrom, syncWatermark); + + const collected = await this.collectorsService.collect< + typeof deploymentsCollectorInputSchema, + typeof deploymentsCollectorOutputSchema + >({ + collectorId, + contract: { + inputSchema: deploymentsCollectorInputSchema, + outputSchema: deploymentsCollectorOutputSchema, + }, + entity, + input: { + ...options.collector.input, + from: syncFrom.toISOString(), + to: options.windowTo.toISOString(), + }, + }); + + await this.deploymentsDb.upsert( + collected.deployments.map(deployment => ({ + catalogEntityRef, + collectorId, + originalDeploymentId: deployment.id, + commitSha: deployment.commitSha, + environment: deployment.environment ?? null, + createdAt: new Date(deployment.createdAt), + result: deployment.result, + })), + ); + } + + /** + * Retrieves and persists incidents + * that were updated after the last successfully synced incident's updatedAt + * for a given entity and collector. + */ + async syncIncidents( + entity: Entity, + options: WindowOptions & CollectorCallOptions, + ): Promise { + const catalogEntityRef = stringifyEntityRef(entity); + const collectorId = options.collector.id; + const watermark = await this.incidentsDb.getLatestUpdatedAt( + catalogEntityRef, + collectorId, + ); + const updatedSince = laterOf(options.windowFrom, watermark); + + const collected = await this.collectorsService.collect< + typeof incidentsCollectorInputSchema, + typeof incidentsCollectorOutputSchema + >({ + collectorId, + contract: { + inputSchema: incidentsCollectorInputSchema, + outputSchema: incidentsCollectorOutputSchema, + }, + entity, + input: { + ...options.collector.input, + from: options.windowFrom.toISOString(), + to: options.windowTo.toISOString(), + updatedSince: updatedSince.toISOString(), + }, + }); + + await this.incidentsDb.upsert( + collected.incidents.map(incident => ({ + catalogEntityRef, + collectorId, + originalIncidentId: incident.id, + createdAt: new Date(incident.createdAt), + updatedAt: new Date(incident.updatedAt), + resolutionAt: incident.resolutionAt + ? new Date(incident.resolutionAt) + : null, + })), + ); + } + + /** + * Retrieves and persists PRs for a deployment when none are stored yet. + * `deploymentId` is the persisted deployments row id (FK). + */ + async syncPullRequestsForDeployment( + entity: Entity, + options: CollectorCallOptions & { + deploymentId: string; + baseCommitSha: string; + headCommitSha: string; + }, + ): Promise { + const catalogEntityRef = stringifyEntityRef(entity); + const collectorId = options.collector.id; + + const existing = + await this.pullRequestsDb.readByEntityCollectorAndDeployment( + catalogEntityRef, + collectorId, + options.deploymentId, + ); + if (existing.length > 0) { + return; + } + + const collected = await this.collectorsService.collect< + typeof deploymentPullRequestsCollectorInputSchema, + typeof deploymentPullRequestsCollectorOutputSchema + >({ + collectorId, + contract: { + inputSchema: deploymentPullRequestsCollectorInputSchema, + outputSchema: deploymentPullRequestsCollectorOutputSchema, + }, + entity, + input: { + ...options.collector.input, + baseCommitSha: options.baseCommitSha, + headCommitSha: options.headCommitSha, + }, + }); + + await this.pullRequestsDb.upsert( + collected.pullRequests.map(pullRequest => ({ + catalogEntityRef, + collectorId, + originalPrId: pullRequest.id, + firstCommitAt: new Date(pullRequest.firstCommitAt), + deploymentId: options.deploymentId, + })), + ); + } +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/syncUtils.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/syncUtils.test.ts new file mode 100644 index 00000000000..c228f71e69f --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/syncUtils.test.ts @@ -0,0 +1,40 @@ +/* + * 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. + */ + +import { laterOf } from './syncUtils'; + +describe('laterOf', () => { + const windowFrom = new Date('2026-06-01T00:00:00.000Z'); + + it('returns windowFrom when watermark is undefined', () => { + expect(laterOf(windowFrom, undefined)).toBe(windowFrom); + }); + + it('returns windowFrom when watermark is earlier', () => { + const watermark = new Date('2026-05-01T00:00:00.000Z'); + expect(laterOf(windowFrom, watermark)).toBe(windowFrom); + }); + + it('returns watermark when it is later than windowFrom', () => { + const watermark = new Date('2026-06-15T00:00:00.000Z'); + expect(laterOf(windowFrom, watermark)).toBe(watermark); + }); + + it('returns watermark when it equals windowFrom', () => { + const watermark = new Date('2026-06-01T00:00:00.000Z'); + expect(laterOf(windowFrom, watermark)).toBe(watermark); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/syncUtils.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/syncUtils.ts new file mode 100644 index 00000000000..b51d836d772 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/syncUtils.ts @@ -0,0 +1,22 @@ +/* + * 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. + */ + +export function laterOf(windowFrom: Date, watermark: Date | undefined): Date { + if (!watermark || watermark < windowFrom) { + return windowFrom; + } + return watermark; +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/types.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/types.ts new file mode 100644 index 00000000000..7f6da2aabb8 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/types.ts @@ -0,0 +1,26 @@ +/* + * 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. + */ + +import type { CollectorConfig } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +export type CollectorCallOptions = { + collector: CollectorConfig; +}; + +export type WindowOptions = { + windowFrom: Date; + windowTo: Date; +}; From b530e1ef7d13a27330263174289cef08b8522b77 Mon Sep 17 00:00:00 2001 From: Dominika Zemanovicova Date: Mon, 3 Aug 2026 13:12:22 +0200 Subject: [PATCH 04/18] Handle concurrent syncs Assisted-By: Cursor Desktop Signed-off-by: Dominika Zemanovicova --- .../src/service/DoraSyncService.test.ts | 69 +++++++++++++++++++ .../src/service/DoraSyncService.ts | 56 +++++++++++++-- .../src/service/syncUtils.test.ts | 67 +++++++++++++++++- .../src/service/syncUtils.ts | 22 ++++++ 4 files changed, 209 insertions(+), 5 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.test.ts index 8f7238ce5eb..817871e0fa6 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.test.ts @@ -140,6 +140,75 @@ describe('DefaultDoraSyncService', () => { }, ); + it.each(databases.eachSupportedId())( + 'coalesces concurrent deployment syncs for the same entity and collector - %p', + async databaseId => { + const { deployments, incidents, pullRequests } = await createTestDatabase( + await databases.init(databaseId), + ); + + let resolveCollect!: () => void; + const collectGate = new Promise(resolve => { + resolveCollect = resolve; + }); + + const deploymentsCollector = buildMockDeploymentsCollector({ + deployments: [ + { + id: '100', + commitSha: 'sha-1', + environment: 'production', + createdAt: '2026-06-10T00:00:00.000Z', + result: 'success', + }, + ], + collectorId: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + }); + jest.mocked(deploymentsCollector.collect).mockImplementation(async () => { + await collectGate; + return { + deployments: [ + { + id: '100', + commitSha: 'sha-1', + environment: 'production', + createdAt: '2026-06-10T00:00:00.000Z', + result: 'success', + }, + ], + }; + }); + + const { collectorsService, collect } = buildMockCollectorsService({ + collectors: [deploymentsCollector], + }); + const syncService = new DefaultDoraSyncService( + collectorsService, + deployments, + incidents, + pullRequests, + ); + + const windowFrom = new Date('2026-06-01T00:00:00.000Z'); + const windowTo = new Date('2026-06-30T00:00:00.000Z'); + const options = { + windowFrom, + windowTo, + collector: { + id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + input: {}, + }, + }; + + const first = syncService.syncDeployments(mockEntity, options); + const second = syncService.syncDeployments(mockEntity, options); + resolveCollect(); + await Promise.all([first, second]); + + expect(collect).toHaveBeenCalledTimes(1); + }, + ); + it.each(databases.eachSupportedId())( 'syncs incidents with created after window start and updated since the last watermark - %p', async databaseId => { diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.ts index b9ff3bc5d49..3e95ebd1b8f 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.ts @@ -32,7 +32,7 @@ import { deploymentPullRequestsCollectorOutputSchema, } from '../metricProviders/schemas/pullRequestSchemas'; import type { WindowOptions, CollectorCallOptions } from './types'; -import { laterOf } from './syncUtils'; +import { coalesceInFlight, laterOf } from './syncUtils'; /** * Collects new DORA source data via collectors and persists it. @@ -57,6 +57,10 @@ export interface DoraSyncService { } export class DefaultDoraSyncService implements DoraSyncService { + private readonly inflightDeployments = new Map>(); + private readonly inflightIncidents = new Map>(); + private readonly inflightPullRequests = new Map>(); + constructor( private readonly collectorsService: ScorecardCollectorsService, private readonly deploymentsDb: DoraDeploymentsStore, @@ -68,12 +72,25 @@ export class DefaultDoraSyncService implements DoraSyncService { * Retrieves and persists deployments * that were created after the last successfully synced deployment's createdAt * for a given entity and collector. + * + * Concurrent syncs for the same entity and collector share one in-flight fetch. */ - async syncDeployments( + syncDeployments( entity: Entity, options: WindowOptions & CollectorCallOptions, ): Promise { const catalogEntityRef = stringifyEntityRef(entity); + const key = `${catalogEntityRef}\0${options.collector.id}`; + return coalesceInFlight(this.inflightDeployments, key, () => + this.doSyncDeployments(entity, options, catalogEntityRef), + ); + } + + private async doSyncDeployments( + entity: Entity, + options: WindowOptions & CollectorCallOptions, + catalogEntityRef: string, + ): Promise { const collectorId = options.collector.id; const syncWatermark = await this.deploymentsDb.getLatestCreatedAt( catalogEntityRef, @@ -115,12 +132,25 @@ export class DefaultDoraSyncService implements DoraSyncService { * Retrieves and persists incidents * that were updated after the last successfully synced incident's updatedAt * for a given entity and collector. + * + * Concurrent syncs for the same entity and collector share one in-flight fetch. */ - async syncIncidents( + syncIncidents( entity: Entity, options: WindowOptions & CollectorCallOptions, ): Promise { const catalogEntityRef = stringifyEntityRef(entity); + const key = `${catalogEntityRef}\0${options.collector.id}`; + return coalesceInFlight(this.inflightIncidents, key, () => + this.doSyncIncidents(entity, options, catalogEntityRef), + ); + } + + private async doSyncIncidents( + entity: Entity, + options: WindowOptions & CollectorCallOptions, + catalogEntityRef: string, + ): Promise { const collectorId = options.collector.id; const watermark = await this.incidentsDb.getLatestUpdatedAt( catalogEntityRef, @@ -163,8 +193,11 @@ export class DefaultDoraSyncService implements DoraSyncService { /** * Retrieves and persists PRs for a deployment when none are stored yet. * `deploymentId` is the persisted deployments row id (FK). + * + * Concurrent syncs for the same entity, collector, and deployment share one + * in-flight fetch. */ - async syncPullRequestsForDeployment( + syncPullRequestsForDeployment( entity: Entity, options: CollectorCallOptions & { deploymentId: string; @@ -173,6 +206,21 @@ export class DefaultDoraSyncService implements DoraSyncService { }, ): Promise { const catalogEntityRef = stringifyEntityRef(entity); + const key = `${catalogEntityRef}\0${options.collector.id}\0${options.deploymentId}`; + return coalesceInFlight(this.inflightPullRequests, key, () => + this.doSyncPullRequestsForDeployment(entity, options, catalogEntityRef), + ); + } + + private async doSyncPullRequestsForDeployment( + entity: Entity, + options: CollectorCallOptions & { + deploymentId: string; + baseCommitSha: string; + headCommitSha: string; + }, + catalogEntityRef: string, + ): Promise { const collectorId = options.collector.id; const existing = diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/syncUtils.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/syncUtils.test.ts index c228f71e69f..0f665548ae1 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/syncUtils.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/syncUtils.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { laterOf } from './syncUtils'; +import { coalesceInFlight, laterOf } from './syncUtils'; describe('laterOf', () => { const windowFrom = new Date('2026-06-01T00:00:00.000Z'); @@ -38,3 +38,68 @@ describe('laterOf', () => { expect(laterOf(windowFrom, watermark)).toBe(watermark); }); }); + +describe('coalesceInFlight', () => { + it('shares one in-flight promise for the same key', async () => { + const inflight = new Map>(); + let resolveRun!: (value: string) => void; + const run = jest.fn( + () => + new Promise(resolve => { + resolveRun = resolve; + }), + ); + + const first = coalesceInFlight(inflight, 'key-a', run); + const second = coalesceInFlight(inflight, 'key-a', run); + + expect(run).toHaveBeenCalledTimes(1); + expect(second).toBe(first); + + resolveRun('done'); + await expect(Promise.all([first, second])).resolves.toEqual([ + 'done', + 'done', + ]); + expect(inflight.size).toBe(0); + }); + + it('runs separately for different keys', async () => { + const inflight = new Map>(); + const runA = jest.fn(async () => 'a'); + const runB = jest.fn(async () => 'b'); + + await expect( + Promise.all([ + coalesceInFlight(inflight, 'key-a', runA), + coalesceInFlight(inflight, 'key-b', runB), + ]), + ).resolves.toEqual(['a', 'b']); + + expect(runA).toHaveBeenCalledTimes(1); + expect(runB).toHaveBeenCalledTimes(1); + }); + + it('allows a new run after the previous one settles', async () => { + const inflight = new Map>(); + const run = jest.fn().mockResolvedValueOnce(1).mockResolvedValueOnce(2); + + await expect(coalesceInFlight(inflight, 'key-a', run)).resolves.toBe(1); + await expect(coalesceInFlight(inflight, 'key-a', run)).resolves.toBe(2); + expect(run).toHaveBeenCalledTimes(2); + }); + + it('clears the key when the run fails so a retry can start', async () => { + const inflight = new Map>(); + const run = jest + .fn() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce('ok'); + + await expect(coalesceInFlight(inflight, 'key-a', run)).rejects.toThrow( + 'boom', + ); + await expect(coalesceInFlight(inflight, 'key-a', run)).resolves.toBe('ok'); + expect(run).toHaveBeenCalledTimes(2); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/syncUtils.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/syncUtils.ts index b51d836d772..87829e566de 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/syncUtils.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/syncUtils.ts @@ -20,3 +20,25 @@ export function laterOf(windowFrom: Date, watermark: Date | undefined): Date { } return watermark; } + +/** + * Shares one in-flight promise per key so concurrent callers wait on the same work. + */ +export function coalesceInFlight( + inflight: Map>, + key: string, + run: () => Promise, +): Promise { + const existing = inflight.get(key); + if (existing) { + return existing; + } + + const promise = run().finally(() => { + if (inflight.get(key) === promise) { + inflight.delete(key); + } + }); + inflight.set(key, promise); + return promise; +} From e032e2094e70f4fcd76ba6aa0551913a65553b8b Mon Sep 17 00:00:00 2001 From: Dominika Zemanovicova Date: Mon, 3 Aug 2026 16:34:58 +0200 Subject: [PATCH 05/18] Add dora last sync database Assisted-By: Cursor Desktop Signed-off-by: Dominika Zemanovicova --- .../migrations/20260723000000_init.js | 9 + .../database/DatabaseDoraDeployments.test.ts | 292 ++++++++++++------ .../src/database/DatabaseDoraDeployments.ts | 22 -- .../database/DatabaseDoraIncidents.test.ts | 209 +++++++++---- .../src/database/DatabaseDoraIncidents.ts | 22 -- .../src/database/DatabaseDoraLastSync.test.ts | 145 +++++++++ .../src/database/DatabaseDoraLastSync.ts | 75 +++++ .../database/DatabaseDoraPullRequests.test.ts | 264 +++++++++------- .../src/database/__fixtures__/testDatabase.ts | 2 + .../src/module.ts | 3 + .../src/service/DoraSyncService.test.ts | 65 ++-- .../src/service/DoraSyncService.ts | 28 +- 12 files changed, 790 insertions(+), 346 deletions(-) create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraLastSync.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraLastSync.ts diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/migrations/20260723000000_init.js b/workspaces/scorecard/plugins/scorecard-backend-module-dora/migrations/20260723000000_init.js index 4953f97bc5e..e31573eb507 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/migrations/20260723000000_init.js +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/migrations/20260723000000_init.js @@ -83,9 +83,18 @@ exports.up = async function up(knex) { '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: 0 }).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'); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.test.ts index 011bae4bc1b..39ba9000691 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.test.ts @@ -24,91 +24,209 @@ describe('DatabaseDoraDeployments', () => { ids: ['POSTGRES_15', 'SQLITE_3'], }); - it.each(databases.eachSupportedId())( - 'upserts, reads cursor and window scoped by collector - %p', - async databaseId => { - const { deployments } = await createTestDatabase( - await databases.init(databaseId), - ); - const entityRef = 'component:default/service-a'; - const collectorId = 'github:deployments'; - const otherCollectorId = 'github:deploymentWorkflowRuns'; - - expect( - await deployments.getLatestCreatedAt(entityRef, collectorId), - ).toBeUndefined(); - - await deployments.upsert([ - { - catalogEntityRef: entityRef, - collectorId: collectorId, - originalDeploymentId: 'dep-1', - commitSha: 'sha-1', - environment: 'production', - createdAt: new Date('2026-06-01T10:00:00.000Z'), - result: 'success', - }, - { - catalogEntityRef: entityRef, - collectorId: collectorId, - originalDeploymentId: 'dep-2', - commitSha: 'sha-2', - environment: 'production', - createdAt: new Date('2026-06-10T10:00:00.000Z'), - result: 'success', - }, - { - // Same original id from a different collector is a distinct row - catalogEntityRef: entityRef, - collectorId: otherCollectorId, - originalDeploymentId: 'dep-2', - commitSha: 'sha-other', - environment: 'production', - createdAt: new Date('2026-06-20T10:00:00.000Z'), - result: 'success', - }, - ]); - - const cursor = await deployments.getLatestCreatedAt( - entityRef, - collectorId, - ); - expect(cursor?.toISOString()).toBe('2026-06-10T10:00:00.000Z'); - - await deployments.upsert([ - { - catalogEntityRef: entityRef, - collectorId: collectorId, - originalDeploymentId: 'dep-2', - commitSha: 'sha-2-updated', - environment: 'production', - createdAt: new Date('2026-06-10T10:00:00.000Z'), - result: 'failure', - }, - ]); - - const windowRows = await deployments.readByEntityCollectorAndWindow( - entityRef, - collectorId, - new Date('2026-06-01T00:00:00.000Z'), - new Date('2026-06-30T00:00:00.000Z'), - ); - - expect(windowRows).toHaveLength(2); - expect(windowRows[1].commitSha).toBe('sha-2-updated'); - expect(windowRows[1].result).toBe('failure'); - expect(windowRows[0].id).toEqual(expect.any(String)); - expect(windowRows[1].id).toEqual(expect.any(String)); - }, - ); - - it.each(databases.eachSupportedId())( - 'no-ops when upserting an empty list - %p', - async databaseId => { - const { deployments } = await createTestDatabase( - await databases.init(databaseId), - ); - await expect(deployments.upsert([])).resolves.toBeUndefined(); - }, - ); + describe('upsert', () => { + it.each(databases.eachSupportedId())( + 'inserts deployments - %p', + async databaseId => { + const { deployments } = await createTestDatabase( + await databases.init(databaseId), + ); + const entityRef = 'component:default/service-a'; + const collectorId = 'github:deployments'; + + await deployments.upsert([ + { + catalogEntityRef: entityRef, + collectorId, + originalDeploymentId: 'dep-1', + commitSha: 'sha-1', + environment: 'production', + createdAt: new Date('2026-06-01T10:00:00.000Z'), + result: 'success', + }, + ]); + + const rows = await deployments.readByEntityCollectorAndWindow( + entityRef, + collectorId, + new Date('2026-06-01T00:00:00.000Z'), + new Date('2026-06-30T00:00:00.000Z'), + ); + + expect(rows).toHaveLength(1); + expect(rows[0].id).toEqual(expect.any(String)); + expect(rows[0].originalDeploymentId).toBe('dep-1'); + expect(rows[0].commitSha).toBe('sha-1'); + }, + ); + + it.each(databases.eachSupportedId())( + 'merges updates on natural key conflict - %p', + async databaseId => { + const { deployments } = await createTestDatabase( + await databases.init(databaseId), + ); + const entityRef = 'component:default/service-a'; + const collectorId = 'github:deployments'; + + await deployments.upsert([ + { + catalogEntityRef: entityRef, + collectorId, + originalDeploymentId: 'dep-1', + commitSha: 'sha-1', + environment: 'production', + createdAt: new Date('2026-06-10T10:00:00.000Z'), + result: 'success', + }, + ]); + // Conflict on (catalog_entity_ref, collector_id, original_deployment_id) for commitSha + await deployments.upsert([ + { + catalogEntityRef: entityRef, + collectorId, + originalDeploymentId: 'dep-1', + commitSha: 'sha-1-updated', + environment: 'production', + createdAt: new Date('2026-06-10T10:00:00.000Z'), + result: 'failure', + }, + ]); + + const rows = await deployments.readByEntityCollectorAndWindow( + entityRef, + collectorId, + new Date('2026-06-01T00:00:00.000Z'), + new Date('2026-06-30T00:00:00.000Z'), + ); + + expect(rows).toHaveLength(1); + expect(rows[0].commitSha).toBe('sha-1-updated'); + expect(rows[0].result).toBe('failure'); + }, + ); + + it.each(databases.eachSupportedId())( + 'treats the same original id from different collectors as distinct - %p', + async databaseId => { + const { deployments } = await createTestDatabase( + await databases.init(databaseId), + ); + const entityRef = 'component:default/service-a'; + + await deployments.upsert([ + { + catalogEntityRef: entityRef, + collectorId: 'github:deployments', + originalDeploymentId: 'dep-1', + commitSha: 'sha-1', + environment: 'production', + createdAt: new Date('2026-06-10T10:00:00.000Z'), + result: 'success', + }, + { + catalogEntityRef: entityRef, + collectorId: 'github:deploymentWorkflowRuns', + originalDeploymentId: 'dep-1', + commitSha: 'sha-other', + environment: 'production', + createdAt: new Date('2026-06-20T10:00:00.000Z'), + result: 'success', + }, + ]); + + const githubRows = await deployments.readByEntityCollectorAndWindow( + entityRef, + 'github:deployments', + new Date('2026-06-01T00:00:00.000Z'), + new Date('2026-06-30T00:00:00.000Z'), + ); + const workflowRows = await deployments.readByEntityCollectorAndWindow( + entityRef, + 'github:deploymentWorkflowRuns', + new Date('2026-06-01T00:00:00.000Z'), + new Date('2026-06-30T00:00:00.000Z'), + ); + + expect(githubRows).toHaveLength(1); + expect(githubRows[0].commitSha).toBe('sha-1'); + expect(workflowRows).toHaveLength(1); + expect(workflowRows[0].commitSha).toBe('sha-other'); + }, + ); + + it.each(databases.eachSupportedId())( + 'no-ops when upserting an empty list - %p', + async databaseId => { + const { deployments } = await createTestDatabase( + await databases.init(databaseId), + ); + await expect(deployments.upsert([])).resolves.toBeUndefined(); + }, + ); + }); + + describe('readByEntityCollectorAndWindow', () => { + it.each(databases.eachSupportedId())( + 'returns rows in the window for the given collector - %p', + async databaseId => { + const { deployments } = await createTestDatabase( + await databases.init(databaseId), + ); + const entityRef = 'component:default/service-a'; + const collectorId = 'github:deployments'; + + await deployments.upsert([ + { + catalogEntityRef: entityRef, + collectorId, + originalDeploymentId: 'dep-before', + commitSha: 'sha-before', + environment: 'production', + createdAt: new Date('2026-05-31T10:00:00.000Z'), + result: 'success', + }, + { + catalogEntityRef: entityRef, + collectorId, + originalDeploymentId: 'dep-1', + commitSha: 'sha-1', + environment: 'production', + createdAt: new Date('2026-06-01T10:00:00.000Z'), + result: 'success', + }, + { + catalogEntityRef: entityRef, + collectorId, + originalDeploymentId: 'dep-2', + commitSha: 'sha-2', + environment: 'production', + createdAt: new Date('2026-06-10T10:00:00.000Z'), + result: 'success', + }, + { + catalogEntityRef: entityRef, + collectorId: 'github:deploymentWorkflowRuns', + originalDeploymentId: 'dep-other', + commitSha: 'sha-other', + environment: 'production', + createdAt: new Date('2026-06-15T10:00:00.000Z'), + result: 'success', + }, + ]); + + const rows = await deployments.readByEntityCollectorAndWindow( + entityRef, + collectorId, + new Date('2026-06-01T00:00:00.000Z'), + new Date('2026-06-30T00:00:00.000Z'), + ); + + expect(rows.map(row => row.originalDeploymentId)).toEqual([ + 'dep-1', + 'dep-2', + ]); + }, + ); + }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.ts index fb4bfee9b26..ebd07ec46d0 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.ts @@ -17,7 +17,6 @@ import { Knex } from 'knex'; import { randomUUID } from 'node:crypto'; import { - asDate, fromDoraDeploymentRow, toDoraDeploymentRow, type DbDoraDeploymentRow, @@ -26,10 +25,6 @@ import { DbDoraDeployment, DbDoraDeploymentCreate } from './types'; export interface DoraDeploymentsStore { upsert(deployments: DbDoraDeploymentCreate[]): Promise; - getLatestCreatedAt( - catalogEntityRef: string, - collectorId: string, - ): Promise; readByEntityCollectorAndWindow( catalogEntityRef: string, collectorId: string, @@ -63,23 +58,6 @@ export class DatabaseDoraDeployments implements DoraDeploymentsStore { .merge(['commit_sha', 'environment', 'created_at', 'result']); } - async getLatestCreatedAt( - catalogEntityRef: string, - collectorId: string, - ): Promise { - const row = await this.dbClient(this.tableName) - .where('catalog_entity_ref', catalogEntityRef) - .andWhere('collector_id', collectorId) - .max('created_at as latest') - .first(); - - if (!row?.latest) { - return undefined; - } - - return asDate(row.latest); - } - async readByEntityCollectorAndWindow( catalogEntityRef: string, collectorId: string, diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.test.ts index 93d9a6d035e..1f8c0bffa27 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.test.ts @@ -24,68 +24,149 @@ describe('DatabaseDoraIncidents', () => { ids: ['POSTGRES_15', 'SQLITE_3'], }); - it.each(databases.eachSupportedId())( - 'upserts resolution updates and reads window - %p', - async databaseId => { - const { incidents } = await createTestDatabase( - await databases.init(databaseId), - ); - const entityRef = 'component:default/service-a'; - const collectorId = 'jira:incidents'; - - expect( - await incidents.getLatestUpdatedAt(entityRef, collectorId), - ).toBeUndefined(); - - await incidents.upsert([ - { - catalogEntityRef: entityRef, - collectorId: collectorId, - originalIncidentId: 'INC-1', - createdAt: new Date('2026-06-01T10:00:00.000Z'), - updatedAt: new Date('2026-06-01T10:00:00.000Z'), - resolutionAt: null, - }, - ]); - - await incidents.upsert([ - { - catalogEntityRef: entityRef, - collectorId: collectorId, - originalIncidentId: 'INC-1', - createdAt: new Date('2026-06-01T10:00:00.000Z'), - updatedAt: new Date('2026-06-02T12:00:00.000Z'), - resolutionAt: new Date('2026-06-02T12:00:00.000Z'), - }, - ]); - - const rows = await incidents.readByEntityCollectorAndWindow( - entityRef, - collectorId, - new Date('2026-06-01T00:00:00.000Z'), - new Date('2026-06-30T00:00:00.000Z'), - ); - - expect(rows).toHaveLength(1); - expect(rows[0].id).toEqual(expect.any(String)); - expect(rows[0].resolutionAt?.toISOString()).toBe( - '2026-06-02T12:00:00.000Z', - ); - expect( - ( - await incidents.getLatestUpdatedAt(entityRef, collectorId) - )?.toISOString(), - ).toBe('2026-06-02T12:00:00.000Z'); - }, - ); - - it.each(databases.eachSupportedId())( - 'no-ops when upserting an empty list - %p', - async databaseId => { - const { incidents } = await createTestDatabase( - await databases.init(databaseId), - ); - await expect(incidents.upsert([])).resolves.toBeUndefined(); - }, - ); + describe('upsert', () => { + it.each(databases.eachSupportedId())( + 'inserts incidents - %p', + async databaseId => { + const { incidents } = await createTestDatabase( + await databases.init(databaseId), + ); + const entityRef = 'component:default/service-a'; + const collectorId = 'jira:incidents'; + + await incidents.upsert([ + { + catalogEntityRef: entityRef, + collectorId, + originalIncidentId: 'INC-1', + createdAt: new Date('2026-06-01T10:00:00.000Z'), + updatedAt: new Date('2026-06-01T10:00:00.000Z'), + resolutionAt: null, + }, + ]); + + const rows = await incidents.readByEntityCollectorAndWindow( + entityRef, + collectorId, + new Date('2026-06-01T00:00:00.000Z'), + new Date('2026-06-30T00:00:00.000Z'), + ); + + expect(rows).toHaveLength(1); + expect(rows[0].id).toEqual(expect.any(String)); + expect(rows[0].originalIncidentId).toBe('INC-1'); + expect(rows[0].resolutionAt).toBeNull(); + }, + ); + + it.each(databases.eachSupportedId())( + 'merges resolution updates on natural key conflict - %p', + async databaseId => { + const { incidents } = await createTestDatabase( + await databases.init(databaseId), + ); + const entityRef = 'component:default/service-a'; + const collectorId = 'jira:incidents'; + + await incidents.upsert([ + { + catalogEntityRef: entityRef, + collectorId, + originalIncidentId: 'INC-1', + createdAt: new Date('2026-06-01T10:00:00.000Z'), + updatedAt: new Date('2026-06-01T10:00:00.000Z'), + resolutionAt: null, + }, + ]); + // Conflict on (catalog_entity_ref, collector_id, original_incident_id) for updatedAt and resolutionAt + await incidents.upsert([ + { + catalogEntityRef: entityRef, + collectorId, + originalIncidentId: 'INC-1', + createdAt: new Date('2026-06-01T10:00:00.000Z'), + updatedAt: new Date('2026-06-02T12:00:00.000Z'), + resolutionAt: new Date('2026-06-02T12:00:00.000Z'), + }, + ]); + + const rows = await incidents.readByEntityCollectorAndWindow( + entityRef, + collectorId, + new Date('2026-06-01T00:00:00.000Z'), + new Date('2026-06-30T00:00:00.000Z'), + ); + + expect(rows).toHaveLength(1); + expect(rows[0].updatedAt?.toISOString()).toBe( + '2026-06-02T12:00:00.000Z', + ); + expect(rows[0].resolutionAt?.toISOString()).toBe( + '2026-06-02T12:00:00.000Z', + ); + }, + ); + + it.each(databases.eachSupportedId())( + 'no-ops when upserting an empty list - %p', + async databaseId => { + const { incidents } = await createTestDatabase( + await databases.init(databaseId), + ); + await expect(incidents.upsert([])).resolves.toBeUndefined(); + }, + ); + }); + + describe('readByEntityCollectorAndWindow', () => { + it.each(databases.eachSupportedId())( + 'returns rows in the window for the given collector - %p', + async databaseId => { + const { incidents } = await createTestDatabase( + await databases.init(databaseId), + ); + const entityRef = 'component:default/service-a'; + const collectorId = 'jira:incidents'; + + await incidents.upsert([ + { + catalogEntityRef: entityRef, + collectorId, + originalIncidentId: 'INC-before', + createdAt: new Date('2026-05-31T10:00:00.000Z'), + updatedAt: new Date('2026-05-31T10:00:00.000Z'), + resolutionAt: null, + }, + { + catalogEntityRef: entityRef, + collectorId, + originalIncidentId: 'INC-1', + createdAt: new Date('2026-06-01T10:00:00.000Z'), + updatedAt: new Date('2026-06-01T10:00:00.000Z'), + resolutionAt: null, + }, + { + catalogEntityRef: entityRef, + collectorId, + originalIncidentId: 'INC-2', + createdAt: new Date('2026-06-10T10:00:00.000Z'), + updatedAt: new Date('2026-06-10T10:00:00.000Z'), + resolutionAt: null, + }, + ]); + + const rows = await incidents.readByEntityCollectorAndWindow( + entityRef, + collectorId, + new Date('2026-06-01T00:00:00.000Z'), + new Date('2026-06-30T00:00:00.000Z'), + ); + + expect(rows.map(row => row.originalIncidentId)).toEqual([ + 'INC-1', + 'INC-2', + ]); + }, + ); + }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.ts index f8ecbdb18da..0ae4c9aa2ad 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.ts @@ -17,7 +17,6 @@ import { Knex } from 'knex'; import { randomUUID } from 'node:crypto'; import { - asDate, fromDoraIncidentRow, toDoraIncidentRow, type DbDoraIncidentRow, @@ -26,10 +25,6 @@ import { DbDoraIncident, DbDoraIncidentCreate } from './types'; export interface DoraIncidentsStore { upsert(incidents: DbDoraIncidentCreate[]): Promise; - getLatestUpdatedAt( - catalogEntityRef: string, - collectorId: string, - ): Promise; readByEntityCollectorAndWindow( catalogEntityRef: string, collectorId: string, @@ -63,23 +58,6 @@ export class DatabaseDoraIncidents implements DoraIncidentsStore { .merge(['created_at', 'updated_at', 'resolution_at']); } - async getLatestUpdatedAt( - catalogEntityRef: string, - collectorId: string, - ): Promise { - const row = await this.dbClient(this.tableName) - .where('catalog_entity_ref', catalogEntityRef) - .andWhere('collector_id', collectorId) - .max('updated_at as latest') - .first(); - - if (!row?.latest) { - return undefined; - } - - return asDate(row.latest); - } - async readByEntityCollectorAndWindow( catalogEntityRef: string, collectorId: string, diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraLastSync.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraLastSync.test.ts new file mode 100644 index 00000000000..34ec4a65593 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraLastSync.test.ts @@ -0,0 +1,145 @@ +/* + * 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. + */ + +import { TestDatabases } from '@backstage/backend-test-utils'; +import { createTestDatabase } from './__fixtures__'; + +jest.setTimeout(60000); + +describe('DatabaseDoraLastSync', () => { + const databases = TestDatabases.create({ + ids: ['POSTGRES_15', 'SQLITE_3'], + }); + + describe('setLastSyncedAt', () => { + it.each(databases.eachSupportedId())( + 'stores last synced at for an entity and collector - %p', + async databaseId => { + const { lastSync } = await createTestDatabase( + await databases.init(databaseId), + ); + const entityRef = 'component:default/service-a'; + const collectorId = 'github:deployments'; + + expect( + await lastSync.getLastSyncedAt(entityRef, collectorId), + ).toBeUndefined(); + + await lastSync.setLastSyncedAt( + entityRef, + collectorId, + new Date('2026-06-10T00:00:00.000Z'), + ); + + expect( + ( + await lastSync.getLastSyncedAt(entityRef, collectorId) + )?.toISOString(), + ).toBe('2026-06-10T00:00:00.000Z'); + }, + ); + + it.each(databases.eachSupportedId())( + 'does not move last synced at backwards - %p', + async databaseId => { + const { lastSync } = await createTestDatabase( + await databases.init(databaseId), + ); + const entityRef = 'component:default/service-a'; + const collectorId = 'github:deployments'; + + await lastSync.setLastSyncedAt( + entityRef, + collectorId, + new Date('2026-06-10T00:00:00.000Z'), + ); + await lastSync.setLastSyncedAt( + entityRef, + collectorId, + new Date('2026-06-09T00:00:00.000Z'), + ); + + expect( + ( + await lastSync.getLastSyncedAt(entityRef, collectorId) + )?.toISOString(), + ).toBe('2026-06-10T00:00:00.000Z'); + }, + ); + + it.each(databases.eachSupportedId())( + 'advances last synced at when the new value is later - %p', + async databaseId => { + const { lastSync } = await createTestDatabase( + await databases.init(databaseId), + ); + const entityRef = 'component:default/service-a'; + const collectorId = 'github:deployments'; + + await lastSync.setLastSyncedAt( + entityRef, + collectorId, + new Date('2026-06-10T00:00:00.000Z'), + ); + await lastSync.setLastSyncedAt( + entityRef, + collectorId, + new Date('2026-06-15T00:00:00.000Z'), + ); + + expect( + ( + await lastSync.getLastSyncedAt(entityRef, collectorId) + )?.toISOString(), + ).toBe('2026-06-15T00:00:00.000Z'); + }, + ); + + it.each(databases.eachSupportedId())( + 'keeps last synced at independent per collector - %p', + async databaseId => { + const { lastSync } = await createTestDatabase( + await databases.init(databaseId), + ); + const entityRef = 'component:default/service-a'; + const collectorId = 'github:deployments'; + const otherCollectorId = 'jira:incidents'; + + await lastSync.setLastSyncedAt( + entityRef, + collectorId, + new Date('2026-06-15T00:00:00.000Z'), + ); + await lastSync.setLastSyncedAt( + entityRef, + otherCollectorId, + new Date('2026-06-01T00:00:00.000Z'), + ); + + expect( + ( + await lastSync.getLastSyncedAt(entityRef, otherCollectorId) + )?.toISOString(), + ).toBe('2026-06-01T00:00:00.000Z'); + expect( + ( + await lastSync.getLastSyncedAt(entityRef, collectorId) + )?.toISOString(), + ).toBe('2026-06-15T00:00:00.000Z'); + }, + ); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraLastSync.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraLastSync.ts new file mode 100644 index 00000000000..c84874dcea4 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraLastSync.ts @@ -0,0 +1,75 @@ +/* + * 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. + */ + +import { Knex } from 'knex'; +import { asDate } from './mappers'; + +export interface DoraLastSyncStore { + getLastSyncedAt( + catalogEntityRef: string, + collectorId: string, + ): Promise; + /** + * Records a successful sync watermark. Only advances when the new value + * is later than the stored one. + */ + setLastSyncedAt( + catalogEntityRef: string, + collectorId: string, + lastSyncedAt: Date, + ): Promise; +} + +export class DatabaseDoraLastSync implements DoraLastSyncStore { + private readonly tableName = 'dora_last_sync'; + + constructor(private readonly dbClient: Knex) {} + + async getLastSyncedAt( + catalogEntityRef: string, + collectorId: string, + ): Promise { + const row = await this.dbClient(this.tableName) + .where('catalog_entity_ref', catalogEntityRef) + .andWhere('collector_id', collectorId) + .select('last_synced_at') + .first(); + + if (!row?.last_synced_at) { + return undefined; + } + + return asDate(row.last_synced_at); + } + + async setLastSyncedAt( + catalogEntityRef: string, + collectorId: string, + lastSyncedAt: Date, + ): Promise { + // Single-statement upsert: insert when missing, otherwise only advance when + // the stored watermark is strictly earlier. + await this.dbClient(this.tableName) + .insert({ + catalog_entity_ref: catalogEntityRef, + collector_id: collectorId, + last_synced_at: lastSyncedAt, + }) + .onConflict(['catalog_entity_ref', 'collector_id']) + .merge(['last_synced_at']) + .where(`${this.tableName}.last_synced_at`, '<', lastSyncedAt); + } +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.test.ts index bfee6eaa1d0..de0533c6cf5 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.test.ts @@ -16,131 +16,161 @@ import { TestDatabases } from '@backstage/backend-test-utils'; import { createTestDatabase } from './__fixtures__'; +import { DatabaseDoraDeployments } from './DatabaseDoraDeployments'; jest.setTimeout(60000); +async function seedDeployment( + deploymentsDb: DatabaseDoraDeployments, + entityRef = 'component:default/service-a', +) { + const deploymentsCollectorId = 'github:deployments'; + await deploymentsDb.upsert([ + { + catalogEntityRef: entityRef, + collectorId: deploymentsCollectorId, + originalDeploymentId: 'dep-1', + commitSha: 'sha-1', + environment: 'production', + createdAt: new Date('2026-06-10T10:00:00.000Z'), + result: 'success', + }, + ]); + const [deployment] = await deploymentsDb.readByEntityCollectorAndWindow( + entityRef, + deploymentsCollectorId, + new Date('2026-06-01T00:00:00.000Z'), + new Date('2026-06-30T00:00:00.000Z'), + ); + return { entityRef, deployment }; +} + describe('DatabaseDoraPullRequests', () => { const databases = TestDatabases.create({ ids: ['POSTGRES_15', 'SQLITE_3'], }); - it.each(databases.eachSupportedId())( - 'upserts and reads by deployment - %p', - async databaseId => { - const { deployments, pullRequests } = await createTestDatabase( - await databases.init(databaseId), - ); - const entityRef = 'component:default/service-a'; - const deploymentsCollectorId = 'github:deployments'; - const prCollectorId = 'github:deploymentRangePullRequests'; - - await deployments.upsert([ - { - catalogEntityRef: entityRef, - collectorId: deploymentsCollectorId, - originalDeploymentId: 'dep-1', - commitSha: 'sha-1', - environment: 'production', - createdAt: new Date('2026-06-10T10:00:00.000Z'), - result: 'success', - }, - ]); - const [deployment] = await deployments.readByEntityCollectorAndWindow( - entityRef, - deploymentsCollectorId, - new Date('2026-06-01T00:00:00.000Z'), - new Date('2026-06-30T00:00:00.000Z'), - ); - - await pullRequests.upsert([ - { - catalogEntityRef: entityRef, - collectorId: prCollectorId, - originalPrId: 'pr-1', - firstCommitAt: new Date('2026-06-09T10:00:00.000Z'), - deploymentId: deployment.id, - }, - ]); - - const rows = await pullRequests.readByEntityCollectorAndDeployment( - entityRef, - prCollectorId, - deployment.id, - ); - - expect(rows).toHaveLength(1); - expect(rows[0].originalPrId).toBe('pr-1'); - expect(rows[0].deploymentId).toBe(deployment.id); - expect(rows[0].id).toEqual(expect.any(String)); - }, - ); + describe('upsert', () => { + it.each(databases.eachSupportedId())( + 'inserts pull requests - %p', + async databaseId => { + const { deployments, pullRequests } = await createTestDatabase( + await databases.init(databaseId), + ); + const { entityRef, deployment } = await seedDeployment(deployments); + const prCollectorId = 'github:deploymentRangePullRequests'; - it.each(databases.eachSupportedId())( - 'merges pull request updates on natural key conflict - %p', - async databaseId => { - const { deployments, pullRequests } = await createTestDatabase( - await databases.init(databaseId), - ); - const entityRef = 'component:default/service-a'; - const prCollectorId = 'github:deploymentRangePullRequests'; - - await deployments.upsert([ - { - catalogEntityRef: entityRef, - collectorId: 'github:deployments', - originalDeploymentId: 'dep-1', - commitSha: 'sha-1', - environment: 'production', - createdAt: new Date('2026-06-10T10:00:00.000Z'), - result: 'success', - }, - ]); - const [deployment] = await deployments.readByEntityCollectorAndWindow( - entityRef, - 'github:deployments', - new Date('2026-06-01T00:00:00.000Z'), - new Date('2026-06-30T00:00:00.000Z'), - ); - - await pullRequests.upsert([ - { - catalogEntityRef: entityRef, - collectorId: prCollectorId, - originalPrId: 'pr-1', - firstCommitAt: new Date('2026-06-09T10:00:00.000Z'), - deploymentId: deployment.id, - }, - ]); - await pullRequests.upsert([ - { - catalogEntityRef: entityRef, - collectorId: prCollectorId, - originalPrId: 'pr-1', - firstCommitAt: new Date('2026-06-09T12:00:00.000Z'), - deploymentId: deployment.id, - }, - ]); - - const rows = await pullRequests.readByEntityCollectorAndDeployment( - entityRef, - prCollectorId, - deployment.id, - ); - - expect(rows).toHaveLength(1); - expect(rows[0].firstCommitAt.toISOString()).toBe( - '2026-06-09T12:00:00.000Z', - ); - }, - ); + await pullRequests.upsert([ + { + catalogEntityRef: entityRef, + collectorId: prCollectorId, + originalPrId: 'pr-1', + firstCommitAt: new Date('2026-06-09T10:00:00.000Z'), + deploymentId: deployment.id, + }, + ]); - it.each(databases.eachSupportedId())( - 'no-ops when upserting an empty list - %p', - async databaseId => { - const { pullRequests } = await createTestDatabase( - await databases.init(databaseId), - ); - await expect(pullRequests.upsert([])).resolves.toBeUndefined(); - }, - ); + const rows = await pullRequests.readByEntityCollectorAndDeployment( + entityRef, + prCollectorId, + deployment.id, + ); + + expect(rows).toHaveLength(1); + expect(rows[0].id).toEqual(expect.any(String)); + expect(rows[0].originalPrId).toBe('pr-1'); + expect(rows[0].deploymentId).toBe(deployment.id); + }, + ); + + it.each(databases.eachSupportedId())( + 'merges updates on natural key conflict - %p', + async databaseId => { + const { deployments, pullRequests } = await createTestDatabase( + await databases.init(databaseId), + ); + const { entityRef, deployment } = await seedDeployment(deployments); + const prCollectorId = 'github:deploymentRangePullRequests'; + + await pullRequests.upsert([ + { + catalogEntityRef: entityRef, + collectorId: prCollectorId, + originalPrId: 'pr-1', + firstCommitAt: new Date('2026-06-09T10:00:00.000Z'), + deploymentId: deployment.id, + }, + ]); + // Conflict on (catalog_entity_ref, collector_id, original_pr_id, deployment_id) for firstCommitAt + await pullRequests.upsert([ + { + catalogEntityRef: entityRef, + collectorId: prCollectorId, + originalPrId: 'pr-1', + firstCommitAt: new Date('2026-06-09T12:00:00.000Z'), + deploymentId: deployment.id, + }, + ]); + + const rows = await pullRequests.readByEntityCollectorAndDeployment( + entityRef, + prCollectorId, + deployment.id, + ); + + expect(rows).toHaveLength(1); + expect(rows[0].firstCommitAt.toISOString()).toBe( + '2026-06-09T12:00:00.000Z', + ); + }, + ); + + it.each(databases.eachSupportedId())( + 'no-ops when upserting an empty list - %p', + async databaseId => { + const { pullRequests } = await createTestDatabase( + await databases.init(databaseId), + ); + await expect(pullRequests.upsert([])).resolves.toBeUndefined(); + }, + ); + }); + + describe('readByEntityCollectorAndDeployment', () => { + it.each(databases.eachSupportedId())( + 'returns pull requests for the given deployment - %p', + async databaseId => { + const { deployments, pullRequests } = await createTestDatabase( + await databases.init(databaseId), + ); + const { entityRef, deployment } = await seedDeployment(deployments); + const prCollectorId = 'github:deploymentRangePullRequests'; + + await pullRequests.upsert([ + { + catalogEntityRef: entityRef, + collectorId: prCollectorId, + originalPrId: 'pr-1', + firstCommitAt: new Date('2026-06-09T10:00:00.000Z'), + deploymentId: deployment.id, + }, + { + catalogEntityRef: entityRef, + collectorId: prCollectorId, + originalPrId: 'pr-2', + firstCommitAt: new Date('2026-06-09T11:00:00.000Z'), + deploymentId: deployment.id, + }, + ]); + + const rows = await pullRequests.readByEntityCollectorAndDeployment( + entityRef, + prCollectorId, + deployment.id, + ); + + expect(rows.map(row => row.originalPrId)).toEqual(['pr-1', 'pr-2']); + }, + ); + }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/__fixtures__/testDatabase.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/__fixtures__/testDatabase.ts index fe0d1ecc927..afc0d30dd7c 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/__fixtures__/testDatabase.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/__fixtures__/testDatabase.ts @@ -18,6 +18,7 @@ import { resolvePackagePath } from '@backstage/backend-plugin-api'; import type { Knex } from 'knex'; import { DatabaseDoraDeployments } from '../DatabaseDoraDeployments'; import { DatabaseDoraIncidents } from '../DatabaseDoraIncidents'; +import { DatabaseDoraLastSync } from '../DatabaseDoraLastSync'; import { DatabaseDoraPullRequests } from '../DatabaseDoraPullRequests'; const migrationsDir = resolvePackagePath( @@ -35,6 +36,7 @@ export async function createTestDatabase(client: Knex) { client, deployments: new DatabaseDoraDeployments(client), incidents: new DatabaseDoraIncidents(client), + lastSync: new DatabaseDoraLastSync(client), pullRequests: new DatabaseDoraPullRequests(client), }; } diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts index 8aeb3420f99..99ddff7fba8 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts @@ -24,6 +24,7 @@ import { import { migrate } from './database/migration'; import { DatabaseDoraDeployments } from './database/DatabaseDoraDeployments'; import { DatabaseDoraIncidents } from './database/DatabaseDoraIncidents'; +import { DatabaseDoraLastSync } from './database/DatabaseDoraLastSync'; import { DatabaseDoraPullRequests } from './database/DatabaseDoraPullRequests'; import { DoraChangeFailureRateProvider } from './metricProviders/DoraChangeFailureRateProvider'; import { DoraDeploymentFrequencyProvider } from './metricProviders/DoraDeploymentFrequencyProvider'; @@ -50,12 +51,14 @@ export const scorecardModuleDora = createBackendModule({ const deploymentsDb = new DatabaseDoraDeployments(dbClient); const incidentsDb = new DatabaseDoraIncidents(dbClient); const pullRequestsDb = new DatabaseDoraPullRequests(dbClient); + const lastSyncDb = new DatabaseDoraLastSync(dbClient); const doraSyncService = new DefaultDoraSyncService( collectorsService, deploymentsDb, incidentsDb, pullRequestsDb, + lastSyncDb, ); const doraDataService = new DefaultDoraDataService( deploymentsDb, diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.test.ts index 817871e0fa6..509001785b4 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.test.ts @@ -38,11 +38,10 @@ describe('DefaultDoraSyncService', () => { }); it.each(databases.eachSupportedId())( - 'syncs deployments created after the last watermark - %p', + 'syncs deployments from the last successful sync watermark - %p', async databaseId => { - const { deployments, incidents, pullRequests } = await createTestDatabase( - await databases.init(databaseId), - ); + const { deployments, incidents, pullRequests, lastSync } = + await createTestDatabase(await databases.init(databaseId)); const deploymentsCollector = buildMockDeploymentsCollector({ deployments: [ @@ -65,6 +64,7 @@ describe('DefaultDoraSyncService', () => { deployments, incidents, pullRequests, + lastSync, ); const dataService = new DefaultDoraDataService( deployments, @@ -73,12 +73,13 @@ describe('DefaultDoraSyncService', () => { ); const windowFrom = new Date('2026-06-01T00:00:00.000Z'); - const windowTo = new Date('2026-06-30T00:00:00.000Z'); + const firstWindowTo = new Date('2026-06-15T00:00:00.000Z'); + const secondWindowTo = new Date('2026-06-30T00:00:00.000Z'); const catalogEntityRef = stringifyEntityRef(mockEntity); await syncService.syncDeployments(mockEntity, { windowFrom, - windowTo, + windowTo: firstWindowTo, collector: { id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, input: {}, @@ -87,7 +88,7 @@ describe('DefaultDoraSyncService', () => { const first = await dataService.readDeployments(catalogEntityRef, { windowFrom, - windowTo, + windowTo: secondWindowTo, collector: { id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID }, }); @@ -96,10 +97,18 @@ describe('DefaultDoraSyncService', () => { expect.objectContaining({ input: expect.objectContaining({ from: windowFrom.toISOString(), - to: windowTo.toISOString(), + to: firstWindowTo.toISOString(), }), }), ); + expect( + ( + await lastSync.getLastSyncedAt( + catalogEntityRef, + DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + ) + )?.toISOString(), + ).toBe(firstWindowTo.toISOString()); jest.mocked(deploymentsCollector.collect).mockResolvedValueOnce({ deployments: [ @@ -107,7 +116,7 @@ describe('DefaultDoraSyncService', () => { id: '101', commitSha: 'sha-2', environment: 'production', - createdAt: '2026-06-15T00:00:00.000Z', + createdAt: '2026-06-20T00:00:00.000Z', result: 'success', }, ], @@ -115,7 +124,7 @@ describe('DefaultDoraSyncService', () => { await syncService.syncDeployments(mockEntity, { windowFrom, - windowTo, + windowTo: secondWindowTo, collector: { id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, input: {}, @@ -124,7 +133,7 @@ describe('DefaultDoraSyncService', () => { const second = await dataService.readDeployments(catalogEntityRef, { windowFrom, - windowTo, + windowTo: secondWindowTo, collector: { id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID }, }); @@ -132,8 +141,8 @@ describe('DefaultDoraSyncService', () => { expect(collect).toHaveBeenLastCalledWith( expect.objectContaining({ input: expect.objectContaining({ - from: '2026-06-10T00:00:00.000Z', // uses latest createdAt watermark - to: windowTo.toISOString(), + from: firstWindowTo.toISOString(), + to: secondWindowTo.toISOString(), }), }), ); @@ -143,9 +152,8 @@ describe('DefaultDoraSyncService', () => { it.each(databases.eachSupportedId())( 'coalesces concurrent deployment syncs for the same entity and collector - %p', async databaseId => { - const { deployments, incidents, pullRequests } = await createTestDatabase( - await databases.init(databaseId), - ); + const { deployments, incidents, pullRequests, lastSync } = + await createTestDatabase(await databases.init(databaseId)); let resolveCollect!: () => void; const collectGate = new Promise(resolve => { @@ -187,6 +195,7 @@ describe('DefaultDoraSyncService', () => { deployments, incidents, pullRequests, + lastSync, ); const windowFrom = new Date('2026-06-01T00:00:00.000Z'); @@ -200,6 +209,9 @@ describe('DefaultDoraSyncService', () => { }, }; + // Hold the first collect open so the sync stays in-flight, then start a + // second sync for the same entity/collector. + // Both should share that one collector call, not run two fetches. const first = syncService.syncDeployments(mockEntity, options); const second = syncService.syncDeployments(mockEntity, options); resolveCollect(); @@ -210,11 +222,10 @@ describe('DefaultDoraSyncService', () => { ); it.each(databases.eachSupportedId())( - 'syncs incidents with created after window start and updated since the last watermark - %p', + 'syncs incidents updated since the last successful sync watermark - %p', async databaseId => { - const { deployments, incidents, pullRequests } = await createTestDatabase( - await databases.init(databaseId), - ); + const { deployments, incidents, pullRequests, lastSync } = + await createTestDatabase(await databases.init(databaseId)); const incidentsCollector = buildMockIncidentsCollector({ incidents: [ @@ -236,14 +247,16 @@ describe('DefaultDoraSyncService', () => { deployments, incidents, pullRequests, + lastSync, ); const windowFrom = new Date('2026-06-01T00:00:00.000Z'); - const windowTo = new Date('2026-06-30T00:00:00.000Z'); + const firstWindowTo = new Date('2026-06-15T00:00:00.000Z'); + const secondWindowTo = new Date('2026-06-30T00:00:00.000Z'); await syncService.syncIncidents(mockEntity, { windowFrom, - windowTo, + windowTo: firstWindowTo, collector: { id: DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, input: {}, @@ -254,7 +267,7 @@ describe('DefaultDoraSyncService', () => { expect.objectContaining({ input: expect.objectContaining({ from: windowFrom.toISOString(), - to: windowTo.toISOString(), + to: firstWindowTo.toISOString(), updatedSince: windowFrom.toISOString(), }), }), @@ -262,7 +275,7 @@ describe('DefaultDoraSyncService', () => { await syncService.syncIncidents(mockEntity, { windowFrom, - windowTo, + windowTo: secondWindowTo, collector: { id: DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, input: {}, @@ -273,8 +286,8 @@ describe('DefaultDoraSyncService', () => { expect.objectContaining({ input: expect.objectContaining({ from: windowFrom.toISOString(), - to: windowTo.toISOString(), - updatedSince: '2026-06-11T00:00:00.000Z', // uses latest updatedAt watermark + to: secondWindowTo.toISOString(), + updatedSince: firstWindowTo.toISOString(), }), }), ); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.ts index 3e95ebd1b8f..e326e8b3dc1 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.ts @@ -18,6 +18,7 @@ import { stringifyEntityRef, type Entity } from '@backstage/catalog-model'; import type { ScorecardCollectorsService } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; import type { DoraDeploymentsStore } from '../database/DatabaseDoraDeployments'; import type { DoraIncidentsStore } from '../database/DatabaseDoraIncidents'; +import type { DoraLastSyncStore } from '../database/DatabaseDoraLastSync'; import type { DoraPullRequestsStore } from '../database/DatabaseDoraPullRequests'; import { deploymentsCollectorInputSchema, @@ -66,11 +67,11 @@ export class DefaultDoraSyncService implements DoraSyncService { private readonly deploymentsDb: DoraDeploymentsStore, private readonly incidentsDb: DoraIncidentsStore, private readonly pullRequestsDb: DoraPullRequestsStore, + private readonly lastSyncDb: DoraLastSyncStore, ) {} /** - * Retrieves and persists deployments - * that were created after the last successfully synced deployment's createdAt + * Retrieves and persists deployments created after the last successful sync * for a given entity and collector. * * Concurrent syncs for the same entity and collector share one in-flight fetch. @@ -92,11 +93,11 @@ export class DefaultDoraSyncService implements DoraSyncService { catalogEntityRef: string, ): Promise { const collectorId = options.collector.id; - const syncWatermark = await this.deploymentsDb.getLatestCreatedAt( + const lastSyncedAt = await this.lastSyncDb.getLastSyncedAt( catalogEntityRef, collectorId, ); - const syncFrom = laterOf(options.windowFrom, syncWatermark); + const syncFrom = laterOf(options.windowFrom, lastSyncedAt); const collected = await this.collectorsService.collect< typeof deploymentsCollectorInputSchema, @@ -126,11 +127,16 @@ export class DefaultDoraSyncService implements DoraSyncService { result: deployment.result, })), ); + + await this.lastSyncDb.setLastSyncedAt( + catalogEntityRef, + collectorId, + options.windowTo, + ); } /** - * Retrieves and persists incidents - * that were updated after the last successfully synced incident's updatedAt + * Retrieves and persists incidents updated after the last successful sync * for a given entity and collector. * * Concurrent syncs for the same entity and collector share one in-flight fetch. @@ -152,11 +158,11 @@ export class DefaultDoraSyncService implements DoraSyncService { catalogEntityRef: string, ): Promise { const collectorId = options.collector.id; - const watermark = await this.incidentsDb.getLatestUpdatedAt( + const lastSyncedAt = await this.lastSyncDb.getLastSyncedAt( catalogEntityRef, collectorId, ); - const updatedSince = laterOf(options.windowFrom, watermark); + const updatedSince = laterOf(options.windowFrom, lastSyncedAt); const collected = await this.collectorsService.collect< typeof incidentsCollectorInputSchema, @@ -188,6 +194,12 @@ export class DefaultDoraSyncService implements DoraSyncService { : null, })), ); + + await this.lastSyncDb.setLastSyncedAt( + catalogEntityRef, + collectorId, + options.windowTo, + ); } /** From a1562018563208c6ea74eb02985505747ff42ff0 Mon Sep 17 00:00:00 2001 From: Dominika Zemanovicova Date: Mon, 3 Aug 2026 17:21:27 +0200 Subject: [PATCH 06/18] Add old data cleanup Assisted-By: Cursor Desktop Signed-off-by: Dominika Zemanovicova --- .../scorecard-backend-module-dora/config.d.ts | 14 +- .../src/constants.ts | 3 + .../database/DatabaseDoraDeployments.test.ts | 49 +++++++ .../src/database/DatabaseDoraDeployments.ts | 7 + .../database/DatabaseDoraIncidents.test.ts | 47 ++++++ .../src/database/DatabaseDoraIncidents.ts | 7 + .../database/DatabaseDoraPullRequests.test.ts | 42 ++++++ .../src/database/DatabaseDoraPullRequests.ts | 7 + .../src/metricProviders/DoraConfig.test.ts | 22 +++ .../src/metricProviders/DoraConfig.ts | 11 ++ .../src/module.ts | 21 ++- .../scheduler/CleanupExpiredDataTask.test.ts | 136 ++++++++++++++++++ .../src/scheduler/CleanupExpiredDataTask.ts | 105 ++++++++++++++ .../src/scheduler/utils.test.ts | 23 +++ .../src/scheduler/utils.ts | 19 +++ 15 files changed, 511 insertions(+), 2 deletions(-) create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/CleanupExpiredDataTask.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/CleanupExpiredDataTask.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/utils.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/utils.ts diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/config.d.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/config.d.ts index 930945328f3..0ffa32080b6 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/config.d.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/config.d.ts @@ -21,7 +21,19 @@ import { } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; export interface Config { - /** Configuration for scorecard dora plugin */ + /** + * 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. + * @default 365 + */ + dataRetentionDays?: number; + }; + /** Configuration for scorecard dora metric providers */ scorecard?: { metricProviders?: { dora?: { diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/constants.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/constants.ts index f0fc15637bd..e74d45e9e6d 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/constants.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/constants.ts @@ -20,3 +20,6 @@ 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_CLEANUP_EXPIRED_DATA_TASK_ID = + 'scorecard-dora:cleanup-expired-data' as const; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.test.ts index 39ba9000691..48919d00609 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.test.ts @@ -229,4 +229,53 @@ describe('DatabaseDoraDeployments', () => { }, ); }); + + describe('deleteOlderThan', () => { + it.each(databases.eachSupportedId())( + 'deletes deployments created before the cutoff - %p', + async databaseId => { + const { deployments } = await createTestDatabase( + await databases.init(databaseId), + ); + const entityRef = 'component:default/service-a'; + const collectorId = 'github:deployments'; + + await deployments.upsert([ + { + catalogEntityRef: entityRef, + collectorId, + originalDeploymentId: 'dep-old', + commitSha: 'sha-old', + environment: 'production', + createdAt: new Date('2025-01-01T00:00:00.000Z'), + result: 'success', + }, + { + catalogEntityRef: entityRef, + collectorId, + originalDeploymentId: 'dep-new', + commitSha: 'sha-new', + environment: 'production', + createdAt: new Date('2026-06-10T00:00:00.000Z'), + result: 'success', + }, + ]); + + const deleted = await deployments.deleteOlderThan( + new Date('2026-01-01T00:00:00.000Z'), + ); + const remaining = await deployments.readByEntityCollectorAndWindow( + entityRef, + collectorId, + new Date('2025-01-01T00:00:00.000Z'), + new Date('2026-12-31T00:00:00.000Z'), + ); + + expect(deleted).toBe(1); + expect(remaining.map(row => row.originalDeploymentId)).toEqual([ + 'dep-new', + ]); + }, + ); + }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.ts index ebd07ec46d0..33ffc55c97f 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.ts @@ -31,6 +31,7 @@ export interface DoraDeploymentsStore { from: Date, to: Date, ): Promise; + deleteOlderThan(olderThan: Date): Promise; } export class DatabaseDoraDeployments implements DoraDeploymentsStore { @@ -74,4 +75,10 @@ export class DatabaseDoraDeployments implements DoraDeploymentsStore { return rows.map(fromDoraDeploymentRow); } + + async deleteOlderThan(olderThan: Date): Promise { + return await this.dbClient(this.tableName) + .where('created_at', '<', olderThan) + .del(); + } } diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.test.ts index 1f8c0bffa27..7efba028ab6 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.test.ts @@ -169,4 +169,51 @@ describe('DatabaseDoraIncidents', () => { }, ); }); + + describe('deleteOlderThan', () => { + it.each(databases.eachSupportedId())( + 'deletes incidents created before the cutoff - %p', + async databaseId => { + const { incidents } = await createTestDatabase( + await databases.init(databaseId), + ); + const entityRef = 'component:default/service-a'; + const collectorId = 'jira:incidents'; + + await incidents.upsert([ + { + catalogEntityRef: entityRef, + collectorId, + originalIncidentId: 'INC-old', + createdAt: new Date('2025-01-01T00:00:00.000Z'), + updatedAt: new Date('2025-01-01T00:00:00.000Z'), + resolutionAt: null, + }, + { + catalogEntityRef: entityRef, + collectorId, + originalIncidentId: 'INC-new', + createdAt: new Date('2026-06-10T00:00:00.000Z'), + updatedAt: new Date('2026-06-10T00:00:00.000Z'), + resolutionAt: null, + }, + ]); + + const deleted = await incidents.deleteOlderThan( + new Date('2026-01-01T00:00:00.000Z'), + ); + const remaining = await incidents.readByEntityCollectorAndWindow( + entityRef, + collectorId, + new Date('2025-01-01T00:00:00.000Z'), + new Date('2026-12-31T00:00:00.000Z'), + ); + + expect(deleted).toBe(1); + expect(remaining.map(row => row.originalIncidentId)).toEqual([ + 'INC-new', + ]); + }, + ); + }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.ts index 0ae4c9aa2ad..740f729c5a5 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.ts @@ -31,6 +31,7 @@ export interface DoraIncidentsStore { from: Date, to: Date, ): Promise; + deleteOlderThan(olderThan: Date): Promise; } export class DatabaseDoraIncidents implements DoraIncidentsStore { @@ -74,4 +75,10 @@ export class DatabaseDoraIncidents implements DoraIncidentsStore { return rows.map(fromDoraIncidentRow); } + + async deleteOlderThan(olderThan: Date): Promise { + return await this.dbClient(this.tableName) + .where('created_at', '<', olderThan) + .del(); + } } diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.test.ts index de0533c6cf5..8d51a0eed1e 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.test.ts @@ -173,4 +173,46 @@ describe('DatabaseDoraPullRequests', () => { }, ); }); + + describe('deleteOlderThan', () => { + it.each(databases.eachSupportedId())( + 'deletes pull requests with first commit before the cutoff - %p', + async databaseId => { + const { deployments, pullRequests } = await createTestDatabase( + await databases.init(databaseId), + ); + const { entityRef, deployment } = await seedDeployment(deployments); + const prCollectorId = 'github:deploymentRangePullRequests'; + + await pullRequests.upsert([ + { + catalogEntityRef: entityRef, + collectorId: prCollectorId, + originalPrId: 'pr-old', + firstCommitAt: new Date('2025-01-01T00:00:00.000Z'), + deploymentId: deployment.id, + }, + { + catalogEntityRef: entityRef, + collectorId: prCollectorId, + originalPrId: 'pr-new', + firstCommitAt: new Date('2026-06-09T10:00:00.000Z'), + deploymentId: deployment.id, + }, + ]); + + const deleted = await pullRequests.deleteOlderThan( + new Date('2026-01-01T00:00:00.000Z'), + ); + const remaining = await pullRequests.readByEntityCollectorAndDeployment( + entityRef, + prCollectorId, + deployment.id, + ); + + expect(deleted).toBe(1); + expect(remaining.map(row => row.originalPrId)).toEqual(['pr-new']); + }, + ); + }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.ts index dcf6b643e5f..0aa41841807 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.ts @@ -30,6 +30,7 @@ export interface DoraPullRequestsStore { collectorId: string, deploymentId: string, ): Promise; + deleteOlderThan(olderThan: Date): Promise; } export class DatabaseDoraPullRequests implements DoraPullRequestsStore { @@ -72,4 +73,10 @@ export class DatabaseDoraPullRequests implements DoraPullRequestsStore { return rows.map(fromDoraPullRequestRow); } + + async deleteOlderThan(olderThan: Date): Promise { + return await this.dbClient(this.tableName) + .where('first_commit_at', '<', olderThan) + .del(); + } } diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraConfig.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraConfig.test.ts index 5f6c710e7a7..f6780923809 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraConfig.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraConfig.test.ts @@ -16,6 +16,7 @@ import { ConfigReader } from '@backstage/config'; import { + DORA_DEFAULT_DATA_RETENTION_DAYS, DORA_DEFAULT_DEPLOYMENT_PULL_REQUESTS_COLLECTOR_ID, DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, @@ -23,6 +24,7 @@ import { } from '../constants'; import { parseDoraChangeFailureRateConfig, + parseDoraDataRetentionDays, parseDoraDeploymentFrequencyConfig, parseDoraMeanTimeToRestoreConfig, parseDoraMedianLeadTimeForChangesConfig, @@ -247,4 +249,24 @@ describe('DoraConfig', () => { }); }); }); + + describe('parseDoraDataRetentionDays', () => { + it('returns the default when unset', () => { + expect(parseDoraDataRetentionDays(new ConfigReader({}))).toBe( + DORA_DEFAULT_DATA_RETENTION_DAYS, + ); + }); + + it('returns the configured value', () => { + expect( + parseDoraDataRetentionDays( + new ConfigReader({ + dora: { + dataRetentionDays: 90, + }, + }), + ), + ).toBe(90); + }); + }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraConfig.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraConfig.ts index b6113c891bd..64a1e3f7b87 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraConfig.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraConfig.ts @@ -21,6 +21,7 @@ import { ThresholdConfig, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; import { + DORA_DEFAULT_DATA_RETENTION_DAYS, DORA_DEFAULT_DEPLOYMENT_PULL_REQUESTS_COLLECTOR_ID, DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, @@ -269,3 +270,13 @@ export function parseDoraChangeFailureRateConfig( ), }; } + +/** + * Parses DORA source-data retention days from the root Backstage config. + */ +export function parseDoraDataRetentionDays(config: Config): number { + return ( + config.getOptionalNumber('dora.dataRetentionDays') ?? + DORA_DEFAULT_DATA_RETENTION_DAYS + ); +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts index 99ddff7fba8..218365511d6 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts @@ -32,6 +32,7 @@ import { DoraMedianLeadTimeForChangesProvider } from './metricProviders/DoraMedi import { DoraMeanTimeToRestoreProvider } from './metricProviders/DoraMeanTimeToRestoreProvider'; import { DefaultDoraDataService } from './service/DoraDataService'; import { DefaultDoraSyncService } from './service/DoraSyncService'; +import { CleanupExpiredDataTask } from './scheduler/CleanupExpiredDataTask'; export const scorecardModuleDora = createBackendModule({ pluginId: 'scorecard', @@ -42,9 +43,18 @@ export const scorecardModuleDora = createBackendModule({ collectorsService: scorecardCollectorsServiceRef, config: coreServices.rootConfig, database: coreServices.database, + logger: coreServices.logger, metrics: scorecardMetricsExtensionPoint, + scheduler: coreServices.scheduler, }, - async init({ collectorsService, config, database, metrics }) { + async init({ + collectorsService, + config, + database, + logger, + metrics, + scheduler, + }) { await migrate(database); const dbClient = await database.getClient(); @@ -87,6 +97,15 @@ export const scorecardModuleDora = createBackendModule({ logger, }), ); + + await new CleanupExpiredDataTask({ + scheduler, + logger, + config, + deployments: deploymentsDb, + incidents: incidentsDb, + pullRequests: pullRequestsDb, + }).start(); }, }); }, diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/CleanupExpiredDataTask.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/CleanupExpiredDataTask.test.ts new file mode 100644 index 00000000000..f177c0bd25d --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/CleanupExpiredDataTask.test.ts @@ -0,0 +1,136 @@ +/* + * 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. + */ + +import { mockServices } from '@backstage/backend-test-utils'; +import { DORA_CLEANUP_EXPIRED_DATA_TASK_ID } from '../constants'; +import { parseDoraDataRetentionDays } from '../metricProviders/DoraConfig'; +import { CleanupExpiredDataTask } from './CleanupExpiredDataTask'; +import { daysToMilliseconds } from './utils'; + +jest.mock('./utils', () => ({ + daysToMilliseconds: jest.fn((days: number) => days * 24 * 60 * 60 * 1000), +})); + +jest.mock('../metricProviders/DoraConfig', () => ({ + parseDoraDataRetentionDays: jest.fn(), +})); + +describe('CleanupExpiredDataTask', () => { + let mockScheduler: ReturnType; + let mockLogger: ReturnType; + let mockConfig: ReturnType; + let mockDeployments: { deleteOlderThan: jest.Mock }; + let mockIncidents: { deleteOlderThan: jest.Mock }; + let mockPullRequests: { deleteOlderThan: jest.Mock }; + let mockTaskRunner: { run: jest.Mock }; + let task: CleanupExpiredDataTask; + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z')); + + mockScheduler = mockServices.scheduler.mock(); + mockLogger = mockServices.logger.mock(); + mockConfig = mockServices.rootConfig.mock(); + mockDeployments = { deleteOlderThan: jest.fn().mockResolvedValue(0) }; + mockIncidents = { deleteOlderThan: jest.fn().mockResolvedValue(0) }; + mockPullRequests = { deleteOlderThan: jest.fn().mockResolvedValue(0) }; + + mockTaskRunner = { + run: jest.fn().mockResolvedValue(undefined), + }; + + mockScheduler.createScheduledTaskRunner.mockReturnValue( + mockTaskRunner as any, + ); + + task = new CleanupExpiredDataTask({ + scheduler: mockScheduler, + logger: mockLogger, + config: mockConfig, + deployments: mockDeployments as any, + incidents: mockIncidents as any, + pullRequests: mockPullRequests as any, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + describe('start', () => { + beforeEach(async () => { + (task as any).cleanupExpiredData = jest.fn().mockResolvedValue(undefined); + await task.start(); + }); + + it('creates a scheduled task runner with the daily schedule', () => { + expect(mockScheduler.createScheduledTaskRunner).toHaveBeenCalledWith({ + frequency: { days: 1 }, + timeout: { minutes: 2 }, + initialDelay: { seconds: 3 }, + }); + }); + + it('runs the task with the scorecard-dora cleanup id', () => { + expect(mockTaskRunner.run).toHaveBeenCalledWith({ + id: DORA_CLEANUP_EXPIRED_DATA_TASK_ID, + fn: expect.any(Function), + }); + }); + }); + + describe('cleanupExpiredData', () => { + const mockDaysToMilliseconds = daysToMilliseconds as jest.Mock; + const mockParseDoraDataRetentionDays = + parseDoraDataRetentionDays as jest.Mock; + + beforeEach(async () => { + mockParseDoraDataRetentionDays.mockReturnValue(2); + mockDeployments.deleteOlderThan.mockResolvedValue(3); + mockIncidents.deleteOlderThan.mockResolvedValue(4); + mockPullRequests.deleteOlderThan.mockResolvedValue(5); + + await (task as any).cleanupExpiredData(mockLogger); + }); + + it('reads retention days from DoraConfig', () => { + expect(mockParseDoraDataRetentionDays).toHaveBeenCalledWith(mockConfig); + }); + + it('converts retention days to milliseconds', () => { + expect(mockDaysToMilliseconds).toHaveBeenCalledWith(2); + }); + + it('deletes data older than the retention cutoff', () => { + const expectedDate = new Date('2024-01-13T12:00:00.000Z'); + expect(mockPullRequests.deleteOlderThan).toHaveBeenCalledWith( + expectedDate, + ); + expect(mockDeployments.deleteOlderThan).toHaveBeenCalledWith( + expectedDate, + ); + expect(mockIncidents.deleteOlderThan).toHaveBeenCalledWith(expectedDate); + }); + + it('logs deleted counts', () => { + expect(mockLogger.info).toHaveBeenCalledWith( + 'Deleted 3 deployments, 4 incidents, 5 pull requests older than 2 days', + ); + }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/CleanupExpiredDataTask.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/CleanupExpiredDataTask.ts new file mode 100644 index 00000000000..4e178b10194 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/CleanupExpiredDataTask.ts @@ -0,0 +1,105 @@ +/* + * 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. + */ + +import { + LoggerService, + SchedulerService, + SchedulerServiceTaskScheduleDefinition, +} from '@backstage/backend-plugin-api'; +import type { Config } from '@backstage/config'; +import { randomUUID } from 'node:crypto'; +import type { DoraDeploymentsStore } from '../database/DatabaseDoraDeployments'; +import type { DoraIncidentsStore } from '../database/DatabaseDoraIncidents'; +import type { DoraPullRequestsStore } from '../database/DatabaseDoraPullRequests'; +import { parseDoraDataRetentionDays } from '../metricProviders/DoraConfig'; +import { DORA_CLEANUP_EXPIRED_DATA_TASK_ID } from '../constants'; +import { daysToMilliseconds } from './utils'; + +type Options = { + scheduler: SchedulerService; + logger: LoggerService; + config: Config; + deployments: DoraDeploymentsStore; + incidents: DoraIncidentsStore; + pullRequests: DoraPullRequestsStore; +}; + +export class CleanupExpiredDataTask { + private readonly config: Config; + private readonly logger: LoggerService; + private readonly scheduler: SchedulerService; + private readonly deployments: DoraDeploymentsStore; + private readonly incidents: DoraIncidentsStore; + private readonly pullRequests: DoraPullRequestsStore; + + private static readonly CLEANUP_SCHEDULE: SchedulerServiceTaskScheduleDefinition = + { + frequency: { days: 1 }, + timeout: { minutes: 2 }, + initialDelay: { seconds: 3 }, + }; + + constructor(options: Options) { + this.config = options.config; + this.logger = options.logger; + this.scheduler = options.scheduler; + this.deployments = options.deployments; + this.incidents = options.incidents; + this.pullRequests = options.pullRequests; + } + + async start(): Promise { + const taskRunner = this.scheduler.createScheduledTaskRunner( + CleanupExpiredDataTask.CLEANUP_SCHEDULE, + ); + + await taskRunner.run({ + id: DORA_CLEANUP_EXPIRED_DATA_TASK_ID, + fn: async () => { + const logger = this.logger.child({ + taskId: DORA_CLEANUP_EXPIRED_DATA_TASK_ID, + taskInstanceId: randomUUID(), + }); + + try { + await this.cleanupExpiredData(logger); + } catch (error) { + logger.error('Failed to cleanup expired DORA data', error); + } + }, + }); + } + + private async cleanupExpiredData(logger: LoggerService): Promise { + const dataRetentionDays = parseDoraDataRetentionDays(this.config); + const olderThan = new Date( + Date.now() - daysToMilliseconds(dataRetentionDays), + ); + + const deletedPullRequests = await this.pullRequests.deleteOlderThan( + olderThan, + ); + const deletedDeployments = await this.deployments.deleteOlderThan( + olderThan, + ); + const deletedIncidents = await this.incidents.deleteOlderThan(olderThan); + + logger.info( + `Deleted ${deletedDeployments} deployments, ${deletedIncidents} incidents, ` + + `${deletedPullRequests} pull requests older than ${dataRetentionDays} days`, + ); + } +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/utils.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/utils.test.ts new file mode 100644 index 00000000000..c6ef0563bb8 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/utils.test.ts @@ -0,0 +1,23 @@ +/* + * 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. + */ + +import { daysToMilliseconds } from './utils'; + +describe('daysToMilliseconds', () => { + it('converts days to milliseconds', () => { + expect(daysToMilliseconds(1)).toBe(86400000); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/utils.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/utils.ts new file mode 100644 index 00000000000..2d2c9e6a54f --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/utils.ts @@ -0,0 +1,19 @@ +/* + * 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. + */ + +export const daysToMilliseconds = (days: number) => { + return days * 24 * 60 * 60 * 1000; +}; From ad03bd8916950403a82a812b4818af1163d0e145 Mon Sep 17 00:00:00 2001 From: Dominika Zemanovicova Date: Wed, 12 Aug 2026 14:10:34 +0200 Subject: [PATCH 07/18] Add staleWindow Assisted-By: Cursor Desktop Signed-off-by: Dominika Zemanovicova --- workspaces/scorecard/app-config.yaml | 6 + .../scorecard-backend-module-dora/README.md | 16 +++ .../scorecard-backend-module-dora/config.d.ts | 36 +++--- .../src/constants.ts | 1 + .../src/metricProviders/DoraConfig.test.ts | 52 ++++++++- .../src/metricProviders/DoraConfig.ts | 19 ++- .../src/module.ts | 3 + .../src/service/DoraSyncService.test.ts | 110 +++++++++++++++++- .../src/service/DoraSyncService.ts | 18 ++- .../src/service/syncUtils.test.ts | 24 +++- .../src/service/syncUtils.ts | 14 +++ 11 files changed, 280 insertions(+), 19 deletions(-) diff --git a/workspaces/scorecard/app-config.yaml b/workspaces/scorecard/app-config.yaml index 0ccf1aab3c3..ce682756634 100644 --- a/workspaces/scorecard/app-config.yaml +++ b/workspaces/scorecard/app-config.yaml @@ -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: diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/README.md b/workspaces/scorecard/plugins/scorecard-backend-module-dora/README.md index 2926b3c0a55..ec07795d651 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/README.md +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/README.md @@ -161,3 +161,19 @@ 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. +- `staleAfterMs`: freshness threshold in milliseconds for deployments and incidents; if the last sync is within this window, those collectors are not refreshed. diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/config.d.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/config.d.ts index 0ffa32080b6..88ebc506408 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/config.d.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/config.d.ts @@ -21,20 +21,30 @@ import { } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; export interface Config { - /** - * 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. - * @default 365 - */ - dataRetentionDays?: number; - }; - /** Configuration for scorecard dora metric providers */ + /** 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. + * @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?: { diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/constants.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/constants.ts index e74d45e9e6d..f813f333664 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/constants.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/constants.ts @@ -21,5 +21,6 @@ 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; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraConfig.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraConfig.test.ts index f6780923809..309178fc992 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraConfig.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraConfig.test.ts @@ -28,7 +28,9 @@ import { parseDoraDeploymentFrequencyConfig, parseDoraMeanTimeToRestoreConfig, parseDoraMedianLeadTimeForChangesConfig, + parseDoraStaleAfterMs, } from './DoraConfig'; +import { DORA_DEFAULT_STALE_AFTER_MS } from '../constants'; describe('DoraConfig', () => { describe('parseDoraDeploymentFrequencyConfig', () => { @@ -261,12 +263,58 @@ describe('DoraConfig', () => { expect( parseDoraDataRetentionDays( new ConfigReader({ - dora: { - dataRetentionDays: 90, + scorecard: { + plugins: { + dora: { + dataRetentionDays: 90, + }, + }, }, }), ), ).toBe(90); }); }); + + describe('parseDoraStaleAfterMs', () => { + it('returns default when unset', () => { + expect(parseDoraStaleAfterMs(new ConfigReader({}))).toBe( + DORA_DEFAULT_STALE_AFTER_MS, + ); + }); + + it('returns configured staleAfterMs in milliseconds', () => { + expect( + parseDoraStaleAfterMs( + new ConfigReader({ + scorecard: { + plugins: { + dora: { + staleAfterMs: 60000, + }, + }, + }, + }), + ), + ).toBe(60000); + }); + + it('throws when configured staleAfterMs is negative', () => { + expect(() => + parseDoraStaleAfterMs( + new ConfigReader({ + scorecard: { + plugins: { + dora: { + staleAfterMs: -1, + }, + }, + }, + }), + ), + ).toThrow( + 'scorecard.plugins.dora.staleAfterMs must be greater than or equal to 0', + ); + }); + }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraConfig.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraConfig.ts index 64a1e3f7b87..7f514c78ecf 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraConfig.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraConfig.ts @@ -26,6 +26,7 @@ import { DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, DORA_DEFAULT_PRODUCTION_ENVIRONMENTS, + DORA_DEFAULT_STALE_AFTER_MS, } from '../constants'; import type { JsonValue } from '@backstage/types'; @@ -276,7 +277,23 @@ export function parseDoraChangeFailureRateConfig( */ export function parseDoraDataRetentionDays(config: Config): number { return ( - config.getOptionalNumber('dora.dataRetentionDays') ?? + config.getOptionalNumber('scorecard.plugins.dora.dataRetentionDays') ?? DORA_DEFAULT_DATA_RETENTION_DAYS ); } + +/** + * Parses collector refresh staleness threshold in milliseconds. + * If last sync is within this window, collector refresh is skipped. + */ +export function parseDoraStaleAfterMs(config: Config): number { + const staleAfterMs = + config.getOptionalNumber('scorecard.plugins.dora.staleAfterMs') ?? + DORA_DEFAULT_STALE_AFTER_MS; + if (staleAfterMs < 0) { + throw new Error( + 'scorecard.plugins.dora.staleAfterMs must be greater than or equal to 0', + ); + } + return staleAfterMs; +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts index 218365511d6..7bce6ffe308 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts @@ -33,6 +33,7 @@ import { DoraMeanTimeToRestoreProvider } from './metricProviders/DoraMeanTimeToR import { DefaultDoraDataService } from './service/DoraDataService'; import { DefaultDoraSyncService } from './service/DoraSyncService'; import { CleanupExpiredDataTask } from './scheduler/CleanupExpiredDataTask'; +import { parseDoraStaleAfterMs } from './metricProviders/DoraConfig'; export const scorecardModuleDora = createBackendModule({ pluginId: 'scorecard', @@ -69,6 +70,8 @@ export const scorecardModuleDora = createBackendModule({ incidentsDb, pullRequestsDb, lastSyncDb, + logger, + parseDoraStaleAfterMs(config), ); const doraDataService = new DefaultDoraDataService( deploymentsDb, diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.test.ts index 509001785b4..545d3d31eef 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.test.ts @@ -15,7 +15,7 @@ */ import { stringifyEntityRef } from '@backstage/catalog-model'; -import { TestDatabases } from '@backstage/backend-test-utils'; +import { TestDatabases, mockServices } from '@backstage/backend-test-utils'; import { buildMockCollectorsService, buildMockDeploymentsCollector, @@ -36,6 +36,11 @@ describe('DefaultDoraSyncService', () => { const databases = TestDatabases.create({ ids: ['POSTGRES_15', 'SQLITE_3'], }); + const logger = mockServices.logger.mock(); + + beforeEach(() => { + logger.debug.mockClear(); + }); it.each(databases.eachSupportedId())( 'syncs deployments from the last successful sync watermark - %p', @@ -65,6 +70,7 @@ describe('DefaultDoraSyncService', () => { incidents, pullRequests, lastSync, + logger, ); const dataService = new DefaultDoraDataService( deployments, @@ -196,6 +202,7 @@ describe('DefaultDoraSyncService', () => { incidents, pullRequests, lastSync, + logger, ); const windowFrom = new Date('2026-06-01T00:00:00.000Z'); @@ -248,6 +255,7 @@ describe('DefaultDoraSyncService', () => { incidents, pullRequests, lastSync, + logger, ); const windowFrom = new Date('2026-06-01T00:00:00.000Z'); @@ -293,4 +301,104 @@ describe('DefaultDoraSyncService', () => { ); }, ); + + it.each(databases.eachSupportedId())( + 'skips deployment and incident refresh when last sync is within staleAfterMs - %p', + async databaseId => { + const { deployments, incidents, pullRequests, lastSync } = + await createTestDatabase(await databases.init(databaseId)); + + const deploymentsCollector = buildMockDeploymentsCollector({ + deployments: [ + { + id: '100', + commitSha: 'sha-1', + environment: 'production', + createdAt: '2026-06-10T00:00:00.000Z', + result: 'success', + }, + ], + collectorId: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + }); + const incidentsCollector = buildMockIncidentsCollector({ + incidents: [ + { + id: 'INC-1', + createdAt: '2026-06-10T00:00:00.000Z', + updatedAt: '2026-06-11T00:00:00.000Z', + resolutionAt: '2026-06-11T00:00:00.000Z', + }, + ], + collectorId: DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, + }); + const { collectorsService, collect } = buildMockCollectorsService({ + collectors: [deploymentsCollector, incidentsCollector], + }); + + const staleAfterMs = 60_000; + const catalogEntityRef = stringifyEntityRef(mockEntity); + const syncService = new DefaultDoraSyncService( + collectorsService, + deployments, + incidents, + pullRequests, + lastSync, + logger, + staleAfterMs, + ); + + const windowTo = new Date(); + const windowFrom = new Date(windowTo.getTime() - 7 * 24 * 60 * 60 * 1000); + const secondWindowTo = new Date(windowTo.getTime() + 30_000); + + await syncService.syncDeployments(mockEntity, { + windowFrom, + windowTo, + collector: { + id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + input: {}, + }, + }); + await syncService.syncIncidents(mockEntity, { + windowFrom, + windowTo, + collector: { + id: DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, + input: {}, + }, + }); + expect(collect).toHaveBeenCalledTimes(2); + + collect.mockClear(); + logger.debug.mockClear(); + + await syncService.syncDeployments(mockEntity, { + windowFrom, + windowTo: secondWindowTo, + collector: { + id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + input: {}, + }, + }); + await syncService.syncIncidents(mockEntity, { + windowFrom, + windowTo: secondWindowTo, + collector: { + id: DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, + input: {}, + }, + }); + + expect(collect).toHaveBeenCalledTimes(0); + expect(logger.debug).toHaveBeenCalledTimes(2); + expect(logger.debug).toHaveBeenNthCalledWith( + 1, + `Skipping DORA deployments refresh for collector "${DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID}" on "${catalogEntityRef}" because data is fresh within staleAfterMs (${staleAfterMs} ms).`, + ); + expect(logger.debug).toHaveBeenNthCalledWith( + 2, + `Skipping DORA incidents refresh for collector "${DORA_DEFAULT_INCIDENTS_COLLECTOR_ID}" on "${catalogEntityRef}" because data is fresh within staleAfterMs (${staleAfterMs} ms).`, + ); + }, + ); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.ts index e326e8b3dc1..6c668e87757 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.ts @@ -15,11 +15,13 @@ */ import { stringifyEntityRef, type Entity } from '@backstage/catalog-model'; +import type { LoggerService } from '@backstage/backend-plugin-api'; import type { ScorecardCollectorsService } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; import type { DoraDeploymentsStore } from '../database/DatabaseDoraDeployments'; import type { DoraIncidentsStore } from '../database/DatabaseDoraIncidents'; import type { DoraLastSyncStore } from '../database/DatabaseDoraLastSync'; import type { DoraPullRequestsStore } from '../database/DatabaseDoraPullRequests'; +import { DORA_DEFAULT_STALE_AFTER_MS } from '../constants'; import { deploymentsCollectorInputSchema, deploymentsCollectorOutputSchema, @@ -33,7 +35,7 @@ import { deploymentPullRequestsCollectorOutputSchema, } from '../metricProviders/schemas/pullRequestSchemas'; import type { WindowOptions, CollectorCallOptions } from './types'; -import { coalesceInFlight, laterOf } from './syncUtils'; +import { coalesceInFlight, isWithinStaleWindow, laterOf } from './syncUtils'; /** * Collects new DORA source data via collectors and persists it. @@ -68,6 +70,8 @@ export class DefaultDoraSyncService implements DoraSyncService { private readonly incidentsDb: DoraIncidentsStore, private readonly pullRequestsDb: DoraPullRequestsStore, private readonly lastSyncDb: DoraLastSyncStore, + private readonly logger: LoggerService, + private readonly staleAfterMs: number = DORA_DEFAULT_STALE_AFTER_MS, ) {} /** @@ -97,6 +101,12 @@ export class DefaultDoraSyncService implements DoraSyncService { catalogEntityRef, collectorId, ); + if (isWithinStaleWindow(lastSyncedAt, this.staleAfterMs)) { + this.logger.debug( + `Skipping DORA deployments refresh for collector "${collectorId}" on "${catalogEntityRef}" because data is fresh within staleAfterMs (${this.staleAfterMs} ms).`, + ); + return; + } const syncFrom = laterOf(options.windowFrom, lastSyncedAt); const collected = await this.collectorsService.collect< @@ -162,6 +172,12 @@ export class DefaultDoraSyncService implements DoraSyncService { catalogEntityRef, collectorId, ); + if (isWithinStaleWindow(lastSyncedAt, this.staleAfterMs)) { + this.logger.debug( + `Skipping DORA incidents refresh for collector "${collectorId}" on "${catalogEntityRef}" because data is fresh within staleAfterMs (${this.staleAfterMs} ms).`, + ); + return; + } const updatedSince = laterOf(options.windowFrom, lastSyncedAt); const collected = await this.collectorsService.collect< diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/syncUtils.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/syncUtils.test.ts index 0f665548ae1..1f3dde47412 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/syncUtils.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/syncUtils.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { coalesceInFlight, laterOf } from './syncUtils'; +import { coalesceInFlight, isWithinStaleWindow, laterOf } from './syncUtils'; describe('laterOf', () => { const windowFrom = new Date('2026-06-01T00:00:00.000Z'); @@ -103,3 +103,25 @@ describe('coalesceInFlight', () => { expect(run).toHaveBeenCalledTimes(2); }); }); + +describe('isWithinStaleWindow', () => { + it('returns false when no previous sync exists', () => { + expect(isWithinStaleWindow(undefined, 60_000)).toBe(false); + }); + + it('returns false when stale window is disabled', () => { + expect(isWithinStaleWindow(new Date(Date.now() - 30_000), 0)).toBe(false); + }); + + it('returns true when last sync is within staleAfter', () => { + expect(isWithinStaleWindow(new Date(Date.now() - 30_000), 60_000)).toBe( + true, + ); + }); + + it('returns false when last sync is outside staleAfter', () => { + expect(isWithinStaleWindow(new Date(Date.now() - 180_000), 60_000)).toBe( + false, + ); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/syncUtils.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/syncUtils.ts index 87829e566de..2c69c4fca10 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/syncUtils.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/syncUtils.ts @@ -21,6 +21,20 @@ export function laterOf(windowFrom: Date, watermark: Date | undefined): Date { return watermark; } +/** + * Returns true when last sync is still considered fresh and a refresh should be skipped. + */ +export function isWithinStaleWindow( + lastSyncedAt: Date | undefined, + staleAfterMs: number, +): boolean { + if (!lastSyncedAt) { + return false; + } + const now = new Date(); + return now.getTime() - lastSyncedAt.getTime() < staleAfterMs; +} + /** * Shares one in-flight promise per key so concurrent callers wait on the same work. */ From 5b56c2e77ae429b4acf33d899ef62748c12f8b4b Mon Sep 17 00:00:00 2001 From: Dominika Zemanovicova Date: Fri, 14 Aug 2026 16:20:29 +0200 Subject: [PATCH 08/18] Simplify tests Co-authored-by: Cursor Signed-off-by: Dominika Zemanovicova --- .../DoraChangeFailureRateProvider.test.ts | 113 +++++------- .../DoraDeploymentFrequencyProvider.test.ts | 83 ++++----- .../DoraMeanTimeToRestoreProvider.test.ts | 73 ++++---- ...raMedianLeadTimeForChangesProvider.test.ts | 164 ++++++++---------- .../src/metricProviders/__fixtures__/index.ts | 2 + .../__fixtures__/mockDoraDataService.ts | 100 +---------- .../__fixtures__/mockDoraStores.ts | 108 ++++++++++++ .../__fixtures__/mockDoraSyncService.ts | 23 +++ .../scheduler/CleanupExpiredDataTask.test.ts | 62 +++---- 9 files changed, 358 insertions(+), 370 deletions(-) create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockDoraStores.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockDoraSyncService.ts diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraChangeFailureRateProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraChangeFailureRateProvider.test.ts index 5b620b53780..9be958e4bf4 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraChangeFailureRateProvider.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraChangeFailureRateProvider.test.ts @@ -18,13 +18,12 @@ import { ConfigReader } from '@backstage/config'; import { mockServices } from '@backstage/backend-test-utils'; import { DoraChangeFailureRateProvider } from './DoraChangeFailureRateProvider'; import { - buildMockDoraServices, dbDeployment, dbIncident, + mockDoraDataService, + mockDoraSyncService, mockEntity, } from './__fixtures__'; -import type { DoraDataService } from '../service/DoraDataService'; -import type { DoraSyncService } from '../service/DoraSyncService'; import { DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, @@ -33,40 +32,37 @@ import { DEFAULT_DORA_CHANGE_FAILURE_RATE_THRESHOLDS } from './DoraConfig'; describe('DoraChangeFailureRateProvider', () => { const mockLogger = mockServices.logger.mock(); - let doraSyncService: jest.Mocked; - let doraDataService: jest.Mocked; let provider: DoraChangeFailureRateProvider; beforeEach(() => { - ({ doraSyncService, doraDataService } = buildMockDoraServices({ - deployments: [ - dbDeployment({ - id: '100', - commitSha: 'sha-1', - environment: 'production', - createdAt: '2026-06-10T00:00:00.000Z', - result: 'success', - }), - dbDeployment({ - id: '101', - commitSha: 'sha-2', - environment: 'production', - createdAt: '2026-06-11T00:00:00.000Z', - result: 'success', - }), - ], - incidents: [ - dbIncident({ - id: 'INC-1', - createdAt: '2026-06-10T12:00:00.000Z', - updatedAt: '2026-06-10T13:00:00.000Z', - resolutionAt: '2026-06-10T13:00:00.000Z', - }), - ], - })); + jest.clearAllMocks(); + mockDoraDataService.readDeployments.mockResolvedValue([ + dbDeployment({ + id: '100', + commitSha: 'sha-1', + environment: 'production', + createdAt: '2026-06-10T00:00:00.000Z', + result: 'success', + }), + dbDeployment({ + id: '101', + commitSha: 'sha-2', + environment: 'production', + createdAt: '2026-06-11T00:00:00.000Z', + result: 'success', + }), + ]); + mockDoraDataService.readIncidents.mockResolvedValue([ + dbIncident({ + id: 'INC-1', + createdAt: '2026-06-10T12:00:00.000Z', + updatedAt: '2026-06-10T13:00:00.000Z', + resolutionAt: '2026-06-10T13:00:00.000Z', + }), + ]); provider = DoraChangeFailureRateProvider.fromConfig(new ConfigReader({}), { - doraSyncService, - doraDataService, + doraSyncService: mockDoraSyncService, + doraDataService: mockDoraDataService, logger: mockLogger, }); }); @@ -87,7 +83,7 @@ describe('DoraChangeFailureRateProvider', () => { it('should sync deployments and incidents with default collectors', async () => { await provider.calculateMetrics(mockEntity); - expect(doraSyncService.syncDeployments).toHaveBeenCalledWith( + expect(mockDoraSyncService.syncDeployments).toHaveBeenCalledWith( mockEntity, expect.objectContaining({ collector: expect.objectContaining({ @@ -95,7 +91,7 @@ describe('DoraChangeFailureRateProvider', () => { }), }), ); - expect(doraSyncService.syncIncidents).toHaveBeenCalledWith( + expect(mockDoraSyncService.syncIncidents).toHaveBeenCalledWith( mockEntity, expect.objectContaining({ collector: expect.objectContaining({ @@ -108,33 +104,6 @@ describe('DoraChangeFailureRateProvider', () => { it('should use custom collectors and pass custom inputs', async () => { const customDeploymentsCollectorId = 'custom:deployments'; const customIncidentsCollectorId = 'custom:incidents'; - const { doraSyncService: sync, doraDataService: data } = - buildMockDoraServices({ - deployments: [ - dbDeployment({ - id: '100', - commitSha: 'sha-1', - environment: 'production', - createdAt: '2026-06-10T00:00:00.000Z', - result: 'success', - }), - dbDeployment({ - id: '101', - commitSha: 'sha-2', - environment: 'production', - createdAt: '2026-06-11T00:00:00.000Z', - result: 'success', - }), - ], - incidents: [ - dbIncident({ - id: 'INC-1', - createdAt: '2026-06-10T12:00:00.000Z', - updatedAt: '2026-06-10T12:00:00.000Z', - resolutionAt: null, - }), - ], - }); const customProvider = DoraChangeFailureRateProvider.fromConfig( new ConfigReader({ scorecard: { @@ -164,15 +133,15 @@ describe('DoraChangeFailureRateProvider', () => { }, }), { - doraSyncService: sync, - doraDataService: data, + doraSyncService: mockDoraSyncService, + doraDataService: mockDoraDataService, logger: mockLogger, }, ); await customProvider.calculateMetrics(mockEntity); - expect(sync.syncDeployments).toHaveBeenCalledWith( + expect(mockDoraSyncService.syncDeployments).toHaveBeenCalledWith( mockEntity, expect.objectContaining({ collector: expect.objectContaining({ @@ -183,7 +152,7 @@ describe('DoraChangeFailureRateProvider', () => { }), }), ); - expect(sync.syncIncidents).toHaveBeenCalledWith( + expect(mockDoraSyncService.syncIncidents).toHaveBeenCalledWith( mockEntity, expect.objectContaining({ collector: expect.objectContaining({ @@ -197,7 +166,7 @@ describe('DoraChangeFailureRateProvider', () => { }); it('should calculate change failure rate using incidents between successful deployments', async () => { - doraDataService.readDeployments.mockResolvedValueOnce([ + mockDoraDataService.readDeployments.mockResolvedValueOnce([ dbDeployment({ id: '100', commitSha: 'sha-1', @@ -220,7 +189,7 @@ describe('DoraChangeFailureRateProvider', () => { result: 'success', }), ]); - doraDataService.readIncidents.mockResolvedValueOnce([ + mockDoraDataService.readIncidents.mockResolvedValueOnce([ dbIncident({ id: 'INC-1', createdAt: '2026-06-10T06:00:00.000Z', // for deployment 100 @@ -241,7 +210,7 @@ describe('DoraChangeFailureRateProvider', () => { }); it('should throw when fewer than 2 successful production deployments are found', async () => { - doraDataService.readDeployments.mockResolvedValueOnce([ + mockDoraDataService.readDeployments.mockResolvedValueOnce([ dbDeployment({ id: '100', commitSha: 'sha-1', @@ -257,7 +226,7 @@ describe('DoraChangeFailureRateProvider', () => { }); it('should throw when fewer than 2 successful deployments are found', async () => { - doraDataService.readDeployments.mockResolvedValueOnce([ + mockDoraDataService.readDeployments.mockResolvedValueOnce([ dbDeployment({ id: '100', commitSha: 'sha-1', @@ -280,7 +249,7 @@ describe('DoraChangeFailureRateProvider', () => { }); it('should throw when fewer than two production deployments are found', async () => { - doraDataService.readDeployments.mockResolvedValueOnce([ + mockDoraDataService.readDeployments.mockResolvedValueOnce([ dbDeployment({ id: '100', commitSha: 'sha-1', @@ -303,7 +272,7 @@ describe('DoraChangeFailureRateProvider', () => { }); it('should throw when all adjacent successful production deployments share createdAt', async () => { - doraDataService.readDeployments.mockResolvedValueOnce([ + mockDoraDataService.readDeployments.mockResolvedValueOnce([ dbDeployment({ id: '100', commitSha: 'sha-1', diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraDeploymentFrequencyProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraDeploymentFrequencyProvider.test.ts index 8e6ff6aab48..0fe361164d4 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraDeploymentFrequencyProvider.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraDeploymentFrequencyProvider.test.ts @@ -17,27 +17,25 @@ import { ConfigReader } from '@backstage/config'; import { DoraDeploymentFrequencyProvider } from './DoraDeploymentFrequencyProvider'; import { - buildMockDoraServices, dbDeployment, + mockDoraDataService, + mockDoraSyncService, mockEntity, } from './__fixtures__'; -import type { DoraDataService } from '../service/DoraDataService'; -import type { DoraSyncService } from '../service/DoraSyncService'; import { DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID } from '../constants'; import { DEFAULT_DORA_DEPLOYMENT_FREQUENCY_THRESHOLDS } from './DoraConfig'; describe('DoraDeploymentFrequencyProvider', () => { - let doraSyncService: jest.Mocked; - let doraDataService: jest.Mocked; let provider: DoraDeploymentFrequencyProvider; beforeEach(() => { - ({ doraSyncService, doraDataService } = buildMockDoraServices()); + jest.clearAllMocks(); + mockDoraDataService.readDeployments.mockResolvedValue([]); provider = DoraDeploymentFrequencyProvider.fromConfig( new ConfigReader({}), { - doraSyncService, - doraDataService, + doraSyncService: mockDoraSyncService, + doraDataService: mockDoraDataService, }, ); }); @@ -58,7 +56,7 @@ describe('DoraDeploymentFrequencyProvider', () => { it('should sync and read with default collectors when no config', async () => { await provider.calculateMetrics(mockEntity); - expect(doraSyncService.syncDeployments).toHaveBeenCalledWith( + expect(mockDoraSyncService.syncDeployments).toHaveBeenCalledWith( mockEntity, expect.objectContaining({ collector: expect.objectContaining({ @@ -67,7 +65,7 @@ describe('DoraDeploymentFrequencyProvider', () => { }), }), ); - expect(doraDataService.readDeployments).toHaveBeenCalledWith( + expect(mockDoraDataService.readDeployments).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ collector: expect.objectContaining({ @@ -79,8 +77,6 @@ describe('DoraDeploymentFrequencyProvider', () => { it('should use custom collectors and pass custom inputs', async () => { const customCollectorId = 'custom:deployments'; - const { doraSyncService: sync, doraDataService: data } = - buildMockDoraServices(); const customProvider = DoraDeploymentFrequencyProvider.fromConfig( new ConfigReader({ @@ -104,14 +100,14 @@ describe('DoraDeploymentFrequencyProvider', () => { }, }), { - doraSyncService: sync, - doraDataService: data, + doraSyncService: mockDoraSyncService, + doraDataService: mockDoraDataService, }, ); await customProvider.calculateMetrics(mockEntity); - expect(sync.syncDeployments).toHaveBeenCalledWith( + expect(mockDoraSyncService.syncDeployments).toHaveBeenCalledWith( mockEntity, expect.objectContaining({ collector: expect.objectContaining({ @@ -125,7 +121,7 @@ describe('DoraDeploymentFrequencyProvider', () => { }); it('should calculate frequency for success result and production environment', async () => { - doraDataService.readDeployments.mockResolvedValueOnce([ + mockDoraDataService.readDeployments.mockResolvedValueOnce([ dbDeployment({ id: '100', commitSha: 'sha-1', @@ -168,7 +164,7 @@ describe('DoraDeploymentFrequencyProvider', () => { }); it('returns 0 when no deployments are collected', async () => { - doraDataService.readDeployments.mockResolvedValueOnce([]); + mockDoraDataService.readDeployments.mockResolvedValueOnce([]); const results = await provider.calculateMetrics(mockEntity); @@ -176,32 +172,29 @@ describe('DoraDeploymentFrequencyProvider', () => { }); it('should treat configured productionEnvironments as production', async () => { - const { doraSyncService: sync, doraDataService: data } = - buildMockDoraServices({ - deployments: [ - dbDeployment({ - id: '100', - commitSha: 'sha-1', - environment: 'prod', - createdAt: '2026-06-01T10:00:00.000Z', - result: 'success', - }), - dbDeployment({ - id: '101', - commitSha: 'sha-2', - environment: 'live', - createdAt: '2026-06-02T10:00:00.000Z', - result: 'success', - }), - dbDeployment({ - id: '102', - commitSha: 'sha-3', - environment: 'production', - createdAt: '2026-06-03T10:00:00.000Z', - result: 'success', - }), - ], - }); + mockDoraDataService.readDeployments.mockResolvedValueOnce([ + dbDeployment({ + id: '100', + commitSha: 'sha-1', + environment: 'prod', + createdAt: '2026-06-01T10:00:00.000Z', + result: 'success', + }), + dbDeployment({ + id: '101', + commitSha: 'sha-2', + environment: 'live', + createdAt: '2026-06-02T10:00:00.000Z', + result: 'success', + }), + dbDeployment({ + id: '102', + commitSha: 'sha-3', + environment: 'production', + createdAt: '2026-06-03T10:00:00.000Z', + result: 'success', + }), + ]); const customProvider = DoraDeploymentFrequencyProvider.fromConfig( new ConfigReader({ @@ -218,8 +211,8 @@ describe('DoraDeploymentFrequencyProvider', () => { }, }), { - doraSyncService: sync, - doraDataService: data, + doraSyncService: mockDoraSyncService, + doraDataService: mockDoraDataService, }, ); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMeanTimeToRestoreProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMeanTimeToRestoreProvider.test.ts index 9c1cd03d65b..4d0693b28cf 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMeanTimeToRestoreProvider.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMeanTimeToRestoreProvider.test.ts @@ -17,32 +17,32 @@ import { ConfigReader } from '@backstage/config'; import { mockServices } from '@backstage/backend-test-utils'; import { DoraMeanTimeToRestoreProvider } from './DoraMeanTimeToRestoreProvider'; -import { buildMockDoraServices, dbIncident, mockEntity } from './__fixtures__'; -import type { DoraDataService } from '../service/DoraDataService'; -import type { DoraSyncService } from '../service/DoraSyncService'; +import { + dbIncident, + mockDoraDataService, + mockDoraSyncService, + mockEntity, +} from './__fixtures__'; import { DORA_DEFAULT_INCIDENTS_COLLECTOR_ID } from '../constants'; import { DEFAULT_DORA_MEAN_TIME_TO_RESTORE_THRESHOLDS } from './DoraConfig'; describe('DoraMeanTimeToRestoreProvider', () => { const mockLogger = mockServices.logger.mock(); - let doraSyncService: jest.Mocked; - let doraDataService: jest.Mocked; let provider: DoraMeanTimeToRestoreProvider; beforeEach(() => { - ({ doraSyncService, doraDataService } = buildMockDoraServices({ - incidents: [ - dbIncident({ - id: 'INC-1', - createdAt: '2026-06-10T10:00:00.000Z', - updatedAt: '2026-06-10T12:00:00.000Z', - resolutionAt: '2026-06-10T12:00:00.000Z', - }), - ], - })); + jest.clearAllMocks(); + mockDoraDataService.readIncidents.mockResolvedValue([ + dbIncident({ + id: 'INC-1', + createdAt: '2026-06-10T10:00:00.000Z', + updatedAt: '2026-06-10T12:00:00.000Z', + resolutionAt: '2026-06-10T12:00:00.000Z', + }), + ]); provider = DoraMeanTimeToRestoreProvider.fromConfig(new ConfigReader({}), { - doraSyncService, - doraDataService, + doraSyncService: mockDoraSyncService, + doraDataService: mockDoraDataService, logger: mockLogger, }); }); @@ -63,7 +63,7 @@ describe('DoraMeanTimeToRestoreProvider', () => { it('should sync and read with default collector when no config', async () => { await provider.calculateMetrics(mockEntity); - expect(doraSyncService.syncIncidents).toHaveBeenCalledWith( + expect(mockDoraSyncService.syncIncidents).toHaveBeenCalledWith( mockEntity, expect.objectContaining({ collector: expect.objectContaining({ @@ -72,7 +72,7 @@ describe('DoraMeanTimeToRestoreProvider', () => { }), }), ); - expect(doraDataService.readIncidents).toHaveBeenCalledWith( + expect(mockDoraDataService.readIncidents).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ collector: expect.objectContaining({ @@ -84,17 +84,14 @@ describe('DoraMeanTimeToRestoreProvider', () => { it('should use custom collector and pass custom inputs', async () => { const customIncidentsCollectorId = 'custom:incidents'; - const { doraSyncService: sync, doraDataService: data } = - buildMockDoraServices({ - incidents: [ - dbIncident({ - id: 'INC-2', - createdAt: '2026-06-10T10:00:00.000Z', - updatedAt: '2026-06-10T12:00:00.000Z', - resolutionAt: '2026-06-10T12:00:00.000Z', - }), - ], - }); + mockDoraDataService.readIncidents.mockResolvedValue([ + dbIncident({ + id: 'INC-2', + createdAt: '2026-06-10T10:00:00.000Z', + updatedAt: '2026-06-10T12:00:00.000Z', + resolutionAt: '2026-06-10T12:00:00.000Z', + }), + ]); const customProvider = DoraMeanTimeToRestoreProvider.fromConfig( new ConfigReader({ scorecard: { @@ -117,15 +114,15 @@ describe('DoraMeanTimeToRestoreProvider', () => { }, }), { - doraSyncService: sync, - doraDataService: data, + doraSyncService: mockDoraSyncService, + doraDataService: mockDoraDataService, logger: mockLogger, }, ); await customProvider.calculateMetrics(mockEntity); - expect(sync.syncIncidents).toHaveBeenCalledWith( + expect(mockDoraSyncService.syncIncidents).toHaveBeenCalledWith( mockEntity, expect.objectContaining({ collector: expect.objectContaining({ @@ -139,7 +136,7 @@ describe('DoraMeanTimeToRestoreProvider', () => { }); it('should calculate mean time to restore in hours', async () => { - doraDataService.readIncidents.mockResolvedValueOnce([ + mockDoraDataService.readIncidents.mockResolvedValueOnce([ dbIncident({ id: 'INC-1', createdAt: '2026-06-10T10:00:00.000Z', @@ -166,7 +163,7 @@ describe('DoraMeanTimeToRestoreProvider', () => { }); it('should throw when no resolved incidents are found', async () => { - doraDataService.readIncidents.mockResolvedValueOnce([ + mockDoraDataService.readIncidents.mockResolvedValueOnce([ dbIncident({ id: 'INC-1', createdAt: '2026-06-10T10:00:00.000Z', @@ -181,7 +178,7 @@ describe('DoraMeanTimeToRestoreProvider', () => { }); it('should throw when no incidents are found', async () => { - doraDataService.readIncidents.mockResolvedValueOnce([]); + mockDoraDataService.readIncidents.mockResolvedValueOnce([]); await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( 'Unable to calculate mean time to restore: no resolved incidents with measurable recovery time were found', @@ -189,7 +186,7 @@ describe('DoraMeanTimeToRestoreProvider', () => { }); it('should throw when resolved incidents are invalid and none are measurable', async () => { - doraDataService.readIncidents.mockResolvedValueOnce([ + mockDoraDataService.readIncidents.mockResolvedValueOnce([ dbIncident({ id: 'INC-1', createdAt: '2026-06-10T12:00:00.000Z', @@ -207,7 +204,7 @@ describe('DoraMeanTimeToRestoreProvider', () => { }); it('should skip invalid resolved incidents and calculate mean from the rest', async () => { - doraDataService.readIncidents.mockResolvedValueOnce([ + mockDoraDataService.readIncidents.mockResolvedValueOnce([ dbIncident({ id: 'INC-1', createdAt: '2026-06-10T12:00:00.000Z', diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMedianLeadTimeForChangesProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMedianLeadTimeForChangesProvider.test.ts index f70c6f62c16..2f94e5d23bc 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMedianLeadTimeForChangesProvider.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMedianLeadTimeForChangesProvider.test.ts @@ -17,13 +17,12 @@ import { ConfigReader } from '@backstage/config'; import { mockServices } from '@backstage/backend-test-utils'; import { - buildMockDoraServices, dbDeployment, dbPullRequest, + mockDoraDataService, + mockDoraSyncService, mockEntity, } from './__fixtures__'; -import type { DoraDataService } from '../service/DoraDataService'; -import type { DoraSyncService } from '../service/DoraSyncService'; import { DoraMedianLeadTimeForChangesProvider } from './DoraMedianLeadTimeForChangesProvider'; import { DORA_DEFAULT_DEPLOYMENT_PULL_REQUESTS_COLLECTOR_ID, @@ -62,20 +61,22 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { }), ]; - let doraSyncService: jest.Mocked; - let doraDataService: jest.Mocked; let provider: DoraMedianLeadTimeForChangesProvider; beforeEach(() => { - ({ doraSyncService, doraDataService } = buildMockDoraServices({ - deployments, + jest.clearAllMocks(); + mockDoraSyncService.syncPullRequestsForDeployment.mockResolvedValue( + undefined, + ); + mockDoraDataService.readDeployments.mockResolvedValue(deployments); + mockDoraDataService.readPullRequestsForDeployment.mockResolvedValue( pullRequests, - })); + ); provider = DoraMedianLeadTimeForChangesProvider.fromConfig( new ConfigReader({}), { - doraSyncService, - doraDataService, + doraSyncService: mockDoraSyncService, + doraDataService: mockDoraDataService, logger: mockLogger, }, ); @@ -97,7 +98,7 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { it('should sync deployments and pull requests with default collectors', async () => { await provider.calculateMetrics(mockEntity); - expect(doraSyncService.syncDeployments).toHaveBeenCalledWith( + expect(mockDoraSyncService.syncDeployments).toHaveBeenCalledWith( mockEntity, expect.objectContaining({ collector: expect.objectContaining({ @@ -107,7 +108,7 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { }), ); expect( - doraSyncService.syncPullRequestsForDeployment, + mockDoraSyncService.syncPullRequestsForDeployment, ).toHaveBeenCalledWith( mockEntity, expect.objectContaining({ @@ -125,12 +126,6 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { const customDeploymentsCollectorId = 'custom:deployments'; const customDeploymentPullRequestsCollectorId = 'custom:deploymentPullRequests'; - const { doraSyncService: sync, doraDataService: data } = - buildMockDoraServices({ - deployments, - pullRequests, - }); - const customProvider = DoraMedianLeadTimeForChangesProvider.fromConfig( new ConfigReader({ scorecard: { @@ -161,15 +156,15 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { }, }), { - doraSyncService: sync, - doraDataService: data, + doraSyncService: mockDoraSyncService, + doraDataService: mockDoraDataService, logger: mockLogger, }, ); await customProvider.calculateMetrics(mockEntity); - expect(sync.syncDeployments).toHaveBeenCalledWith( + expect(mockDoraSyncService.syncDeployments).toHaveBeenCalledWith( mockEntity, expect.objectContaining({ collector: expect.objectContaining({ @@ -181,7 +176,9 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { }), }), ); - expect(sync.syncPullRequestsForDeployment).toHaveBeenCalledWith( + expect( + mockDoraSyncService.syncPullRequestsForDeployment, + ).toHaveBeenCalledWith( mockEntity, expect.objectContaining({ collector: expect.objectContaining({ @@ -204,7 +201,7 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { }); it('should calculate median with multiple pull requests across multiple deployment ranges', async () => { - doraDataService.readDeployments.mockResolvedValueOnce([ + mockDoraDataService.readDeployments.mockResolvedValueOnce([ dbDeployment({ id: '400', commitSha: 'sha-1', @@ -227,7 +224,7 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { result: 'success', }), ]); - doraDataService.readPullRequestsForDeployment + mockDoraDataService.readPullRequestsForDeployment .mockResolvedValueOnce([ dbPullRequest({ id: '501', @@ -252,10 +249,10 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { expect(results.get('dora.medianLeadTimeForChanges')).toBe(12); expect( - doraSyncService.syncPullRequestsForDeployment, + mockDoraSyncService.syncPullRequestsForDeployment, ).toHaveBeenCalledTimes(2); expect( - doraSyncService.syncPullRequestsForDeployment, + mockDoraSyncService.syncPullRequestsForDeployment, ).toHaveBeenNthCalledWith( 1, mockEntity, @@ -266,7 +263,7 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { }), ); expect( - doraSyncService.syncPullRequestsForDeployment, + mockDoraSyncService.syncPullRequestsForDeployment, ).toHaveBeenNthCalledWith( 2, mockEntity, @@ -279,36 +276,33 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { }); it('should throw when fewer than 2 successful production deployments are found', async () => { - doraDataService.readDeployments.mockResolvedValueOnce([]); + mockDoraDataService.readDeployments.mockResolvedValueOnce([]); await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( /need at least 2 successful production deployments/, ); expect( - doraSyncService.syncPullRequestsForDeployment, + mockDoraSyncService.syncPullRequestsForDeployment, ).not.toHaveBeenCalled(); }); it('should use configured productionEnvironments when filtering deployments', async () => { - const { doraSyncService: sync, doraDataService: data } = - buildMockDoraServices({ - deployments: [ - dbDeployment({ - id: '400', - commitSha: 'sha-1', - environment: 'production', - createdAt: '2026-06-10T00:00:00.000Z', - result: 'success', - }), - dbDeployment({ - id: '401', - commitSha: 'sha-2', - environment: 'prod', - createdAt: '2026-06-11T00:00:00.000Z', - result: 'success', - }), - ], - }); + mockDoraDataService.readDeployments.mockResolvedValueOnce([ + dbDeployment({ + id: '400', + commitSha: 'sha-1', + environment: 'production', + createdAt: '2026-06-10T00:00:00.000Z', + result: 'success', + }), + dbDeployment({ + id: '401', + commitSha: 'sha-2', + environment: 'prod', + createdAt: '2026-06-11T00:00:00.000Z', + result: 'success', + }), + ]); const customProvider = DoraMedianLeadTimeForChangesProvider.fromConfig( new ConfigReader({ @@ -325,8 +319,8 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { }, }), { - doraSyncService: sync, - doraDataService: data, + doraSyncService: mockDoraSyncService, + doraDataService: mockDoraDataService, logger: mockLogger, }, ); @@ -337,7 +331,7 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { }); it('should throw when no pull requests with measurable lead time are found', async () => { - doraDataService.readPullRequestsForDeployment.mockResolvedValue([]); + mockDoraDataService.readPullRequestsForDeployment.mockResolvedValue([]); await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( /no pull requests with a measurable lead time/, @@ -345,43 +339,33 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { }); it('should skip deployment intervals when pull request sync fails and warn', async () => { - const { doraSyncService: sync, doraDataService: data } = - buildMockDoraServices({ - deployments: [ - dbDeployment({ - id: '100', - commitSha: 'sha-1', - environment: 'production', - createdAt: '2026-06-10T00:00:00.000Z', - result: 'success', - }), - dbDeployment({ - id: '101', - commitSha: 'sha-2', - environment: 'production', - createdAt: '2026-06-11T00:00:00.000Z', - result: 'success', - }), - dbDeployment({ - id: '102', - commitSha: 'sha-3', - environment: 'production', - createdAt: '2026-06-12T00:00:00.000Z', - result: 'success', - }), - ], - pullRequests: [ - dbPullRequest({ - id: '503', - firstCommitAt: '2026-06-11T12:00:00.000Z', // 12h - deploymentId: '102', - }), - ], - }); - sync.syncPullRequestsForDeployment + mockDoraDataService.readDeployments.mockResolvedValue([ + dbDeployment({ + id: '100', + commitSha: 'sha-1', + environment: 'production', + createdAt: '2026-06-10T00:00:00.000Z', + result: 'success', + }), + dbDeployment({ + id: '101', + commitSha: 'sha-2', + environment: 'production', + createdAt: '2026-06-11T00:00:00.000Z', + result: 'success', + }), + dbDeployment({ + id: '102', + commitSha: 'sha-3', + environment: 'production', + createdAt: '2026-06-12T00:00:00.000Z', + result: 'success', + }), + ]); + mockDoraSyncService.syncPullRequestsForDeployment .mockRejectedValueOnce(new Error('GitHub compare failed')) .mockResolvedValueOnce(undefined); - data.readPullRequestsForDeployment.mockResolvedValue([ + mockDoraDataService.readPullRequestsForDeployment.mockResolvedValue([ dbPullRequest({ id: '503', firstCommitAt: '2026-06-11T12:00:00.000Z', @@ -392,8 +376,8 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { const results = await DoraMedianLeadTimeForChangesProvider.fromConfig( new ConfigReader({}), { - doraSyncService: sync, - doraDataService: data, + doraSyncService: mockDoraSyncService, + doraDataService: mockDoraDataService, logger: mockLogger, }, ).calculateMetrics(mockEntity); @@ -411,7 +395,7 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { }); it('should skip pull requests with negative lead time and warn', async () => { - doraDataService.readPullRequestsForDeployment.mockResolvedValue([ + mockDoraDataService.readPullRequestsForDeployment.mockResolvedValue([ dbPullRequest({ id: '999', firstCommitAt: '2026-06-09T12:00:00.000Z', // after sha-current deployment @@ -436,7 +420,7 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { }); it('should throw when all deployment intervals fail to sync pull requests', async () => { - doraSyncService.syncPullRequestsForDeployment.mockRejectedValue( + mockDoraSyncService.syncPullRequestsForDeployment.mockRejectedValue( new Error('collector unavailable'), ); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/index.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/index.ts index 0ed8e7e4605..4caef695790 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/index.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/index.ts @@ -17,4 +17,6 @@ export * from './mockCollectors'; export * from './mockCollectorsService'; export * from './mockDoraDataService'; +export * from './mockDoraStores'; +export * from './mockDoraSyncService'; export * from './mockEntity'; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockDoraDataService.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockDoraDataService.ts index 899fac96d22..bd0646a217f 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockDoraDataService.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockDoraDataService.ts @@ -14,100 +14,10 @@ * limitations under the License. */ -import { asDate } from '../../database/mappers'; -import type { - DbDoraDeployment, - DbDoraIncident, - DbDoraPullRequest, -} from '../../database/types'; import type { DoraDataService } from '../../service/DoraDataService'; -import type { DoraSyncService } from '../../service/DoraSyncService'; -const DEFAULT_ENTITY_REF = 'component:default/mock'; - -export function dbDeployment(partial: { - id: string; - commitSha: string; - environment?: string | null; - createdAt: string | Date; - result: string; - catalogEntityRef?: string; - collectorId?: string; - originalDeploymentId?: string; -}): DbDoraDeployment { - return { - id: partial.id, - catalogEntityRef: partial.catalogEntityRef ?? DEFAULT_ENTITY_REF, - collectorId: partial.collectorId ?? 'github:deployments', - originalDeploymentId: partial.originalDeploymentId ?? partial.id, - commitSha: partial.commitSha, - environment: partial.environment ?? null, - createdAt: asDate(partial.createdAt), - result: partial.result, - }; -} - -export function dbIncident(partial: { - id: string; - createdAt: string | Date; - updatedAt: string | Date; - resolutionAt?: string | Date | null; - catalogEntityRef?: string; - collectorId?: string; - originalIncidentId?: string; -}): DbDoraIncident { - return { - id: partial.id, - catalogEntityRef: partial.catalogEntityRef ?? DEFAULT_ENTITY_REF, - collectorId: partial.collectorId ?? 'jira:incidents', - originalIncidentId: partial.originalIncidentId ?? partial.id, - createdAt: asDate(partial.createdAt), - updatedAt: asDate(partial.updatedAt), - resolutionAt: - partial.resolutionAt === undefined || partial.resolutionAt === null - ? null - : asDate(partial.resolutionAt), - }; -} - -export function dbPullRequest(partial: { - id: string; - firstCommitAt: string | Date; - deploymentId?: string | null; - catalogEntityRef?: string; - collectorId?: string; - originalPrId?: string; -}): DbDoraPullRequest { - return { - id: partial.id, - catalogEntityRef: partial.catalogEntityRef ?? DEFAULT_ENTITY_REF, - collectorId: partial.collectorId ?? 'github:deploymentPullRequests', - originalPrId: partial.originalPrId ?? partial.id, - firstCommitAt: asDate(partial.firstCommitAt), - deploymentId: partial.deploymentId ?? null, - }; -} - -export function buildMockDoraServices(options?: { - deployments?: DbDoraDeployment[]; - incidents?: DbDoraIncident[]; - pullRequests?: DbDoraPullRequest[]; -}): { - doraSyncService: jest.Mocked; - doraDataService: jest.Mocked; -} { - return { - doraSyncService: { - syncDeployments: jest.fn().mockResolvedValue(undefined), - syncIncidents: jest.fn().mockResolvedValue(undefined), - syncPullRequestsForDeployment: jest.fn().mockResolvedValue(undefined), - } as unknown as jest.Mocked, - doraDataService: { - readDeployments: jest.fn().mockResolvedValue(options?.deployments ?? []), - readIncidents: jest.fn().mockResolvedValue(options?.incidents ?? []), - readPullRequestsForDeployment: jest - .fn() - .mockResolvedValue(options?.pullRequests ?? []), - } as unknown as jest.Mocked, - }; -} +export const mockDoraDataService: jest.Mocked = { + readDeployments: jest.fn(), + readIncidents: jest.fn(), + readPullRequestsForDeployment: jest.fn(), +}; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockDoraStores.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockDoraStores.ts new file mode 100644 index 00000000000..c4a2a94e58c --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockDoraStores.ts @@ -0,0 +1,108 @@ +/* + * 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. + */ + +import type { DoraDeploymentsStore } from '../../database/DatabaseDoraDeployments'; +import type { DoraIncidentsStore } from '../../database/DatabaseDoraIncidents'; +import type { DoraPullRequestsStore } from '../../database/DatabaseDoraPullRequests'; +import { asDate } from '../../database/mappers'; +import type { + DbDoraDeployment, + DbDoraIncident, + DbDoraPullRequest, +} from '../../database/types'; + +const DEFAULT_ENTITY_REF = 'component:default/mock'; + +export function dbDeployment(partial: { + id: string; + commitSha: string; + environment?: string | null; + createdAt: string | Date; + result: string; + catalogEntityRef?: string; + collectorId?: string; + originalDeploymentId?: string; +}): DbDoraDeployment { + return { + id: partial.id, + catalogEntityRef: partial.catalogEntityRef ?? DEFAULT_ENTITY_REF, + collectorId: partial.collectorId ?? 'github:deployments', + originalDeploymentId: partial.originalDeploymentId ?? partial.id, + commitSha: partial.commitSha, + environment: partial.environment ?? null, + createdAt: asDate(partial.createdAt), + result: partial.result, + }; +} + +export function dbIncident(partial: { + id: string; + createdAt: string | Date; + updatedAt: string | Date; + resolutionAt?: string | Date | null; + catalogEntityRef?: string; + collectorId?: string; + originalIncidentId?: string; +}): DbDoraIncident { + return { + id: partial.id, + catalogEntityRef: partial.catalogEntityRef ?? DEFAULT_ENTITY_REF, + collectorId: partial.collectorId ?? 'jira:incidents', + originalIncidentId: partial.originalIncidentId ?? partial.id, + createdAt: asDate(partial.createdAt), + updatedAt: asDate(partial.updatedAt), + resolutionAt: + partial.resolutionAt === undefined || partial.resolutionAt === null + ? null + : asDate(partial.resolutionAt), + }; +} + +export function dbPullRequest(partial: { + id: string; + firstCommitAt: string | Date; + deploymentId?: string | null; + catalogEntityRef?: string; + collectorId?: string; + originalPrId?: string; +}): DbDoraPullRequest { + return { + id: partial.id, + catalogEntityRef: partial.catalogEntityRef ?? DEFAULT_ENTITY_REF, + collectorId: partial.collectorId ?? 'github:deploymentPullRequests', + originalPrId: partial.originalPrId ?? partial.id, + firstCommitAt: asDate(partial.firstCommitAt), + deploymentId: partial.deploymentId ?? null, + }; +} + +export const mockDoraDeploymentsStore: jest.Mocked = { + upsert: jest.fn(), + readByEntityCollectorAndWindow: jest.fn(), + deleteOlderThan: jest.fn(), +}; + +export const mockDoraIncidentsStore: jest.Mocked = { + upsert: jest.fn(), + readByEntityCollectorAndWindow: jest.fn(), + deleteOlderThan: jest.fn(), +}; + +export const mockDoraPullRequestsStore: jest.Mocked = { + upsert: jest.fn(), + readByEntityCollectorAndDeployment: jest.fn(), + deleteOlderThan: jest.fn(), +}; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockDoraSyncService.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockDoraSyncService.ts new file mode 100644 index 00000000000..5c6fb4b4c99 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockDoraSyncService.ts @@ -0,0 +1,23 @@ +/* + * 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. + */ + +import type { DoraSyncService } from '../../service/DoraSyncService'; + +export const mockDoraSyncService: jest.Mocked = { + syncDeployments: jest.fn(), + syncIncidents: jest.fn(), + syncPullRequestsForDeployment: jest.fn(), +}; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/CleanupExpiredDataTask.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/CleanupExpiredDataTask.test.ts index f177c0bd25d..ad82f3d4a43 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/CleanupExpiredDataTask.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/CleanupExpiredDataTask.test.ts @@ -14,56 +14,60 @@ * limitations under the License. */ +import { + LoggerService, + SchedulerService, + SchedulerServiceTaskRunner, +} from '@backstage/backend-plugin-api'; import { mockServices } from '@backstage/backend-test-utils'; +import type { Config } from '@backstage/config'; import { DORA_CLEANUP_EXPIRED_DATA_TASK_ID } from '../constants'; import { parseDoraDataRetentionDays } from '../metricProviders/DoraConfig'; +import { + mockDoraDeploymentsStore, + mockDoraIncidentsStore, + mockDoraPullRequestsStore, +} from '../metricProviders/__fixtures__'; import { CleanupExpiredDataTask } from './CleanupExpiredDataTask'; -import { daysToMilliseconds } from './utils'; - -jest.mock('./utils', () => ({ - daysToMilliseconds: jest.fn((days: number) => days * 24 * 60 * 60 * 1000), -})); jest.mock('../metricProviders/DoraConfig', () => ({ parseDoraDataRetentionDays: jest.fn(), })); describe('CleanupExpiredDataTask', () => { - let mockScheduler: ReturnType; - let mockLogger: ReturnType; - let mockConfig: ReturnType; - let mockDeployments: { deleteOlderThan: jest.Mock }; - let mockIncidents: { deleteOlderThan: jest.Mock }; - let mockPullRequests: { deleteOlderThan: jest.Mock }; - let mockTaskRunner: { run: jest.Mock }; + let mockScheduler: jest.Mocked; + let mockLogger: jest.Mocked; + let mockConfig: Config; + let mockTaskRunner: jest.Mocked>; let task: CleanupExpiredDataTask; beforeEach(() => { jest.useFakeTimers(); jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z')); + jest.clearAllMocks(); mockScheduler = mockServices.scheduler.mock(); mockLogger = mockServices.logger.mock(); mockConfig = mockServices.rootConfig.mock(); - mockDeployments = { deleteOlderThan: jest.fn().mockResolvedValue(0) }; - mockIncidents = { deleteOlderThan: jest.fn().mockResolvedValue(0) }; - mockPullRequests = { deleteOlderThan: jest.fn().mockResolvedValue(0) }; + mockDoraDeploymentsStore.deleteOlderThan.mockResolvedValue(0); + mockDoraIncidentsStore.deleteOlderThan.mockResolvedValue(0); + mockDoraPullRequestsStore.deleteOlderThan.mockResolvedValue(0); mockTaskRunner = { run: jest.fn().mockResolvedValue(undefined), }; mockScheduler.createScheduledTaskRunner.mockReturnValue( - mockTaskRunner as any, + mockTaskRunner as SchedulerServiceTaskRunner, ); task = new CleanupExpiredDataTask({ scheduler: mockScheduler, logger: mockLogger, config: mockConfig, - deployments: mockDeployments as any, - incidents: mockIncidents as any, - pullRequests: mockPullRequests as any, + deployments: mockDoraDeploymentsStore, + incidents: mockDoraIncidentsStore, + pullRequests: mockDoraPullRequestsStore, }); }); @@ -95,15 +99,14 @@ describe('CleanupExpiredDataTask', () => { }); describe('cleanupExpiredData', () => { - const mockDaysToMilliseconds = daysToMilliseconds as jest.Mock; const mockParseDoraDataRetentionDays = parseDoraDataRetentionDays as jest.Mock; beforeEach(async () => { mockParseDoraDataRetentionDays.mockReturnValue(2); - mockDeployments.deleteOlderThan.mockResolvedValue(3); - mockIncidents.deleteOlderThan.mockResolvedValue(4); - mockPullRequests.deleteOlderThan.mockResolvedValue(5); + mockDoraDeploymentsStore.deleteOlderThan.mockResolvedValue(3); + mockDoraIncidentsStore.deleteOlderThan.mockResolvedValue(4); + mockDoraPullRequestsStore.deleteOlderThan.mockResolvedValue(5); await (task as any).cleanupExpiredData(mockLogger); }); @@ -112,19 +115,18 @@ describe('CleanupExpiredDataTask', () => { expect(mockParseDoraDataRetentionDays).toHaveBeenCalledWith(mockConfig); }); - it('converts retention days to milliseconds', () => { - expect(mockDaysToMilliseconds).toHaveBeenCalledWith(2); - }); - it('deletes data older than the retention cutoff', () => { + // today is 2024-01-15T12:00:00.000Z, cutoff is 2 days const expectedDate = new Date('2024-01-13T12:00:00.000Z'); - expect(mockPullRequests.deleteOlderThan).toHaveBeenCalledWith( + expect(mockDoraPullRequestsStore.deleteOlderThan).toHaveBeenCalledWith( + expectedDate, + ); + expect(mockDoraDeploymentsStore.deleteOlderThan).toHaveBeenCalledWith( expectedDate, ); - expect(mockDeployments.deleteOlderThan).toHaveBeenCalledWith( + expect(mockDoraIncidentsStore.deleteOlderThan).toHaveBeenCalledWith( expectedDate, ); - expect(mockIncidents.deleteOlderThan).toHaveBeenCalledWith(expectedDate); }); it('logs deleted counts', () => { From ee1dc098333c6807dd688b97a46c6ad268d47fbb Mon Sep 17 00:00:00 2001 From: Dominika Zemanovicova Date: Fri, 14 Aug 2026 16:48:23 +0200 Subject: [PATCH 09/18] Save only successful deployments Co-authored-by: Cursor Signed-off-by: Dominika Zemanovicova --- .../docs/metrics/change-failure-rate.md | 2 + .../docs/metrics/deployment-frequency.md | 2 + .../metrics/median-lead-time-for-changes.md | 6 +- .../migrations/20260723000000_init.js | 1 - .../database/DatabaseDoraDeployments.test.ts | 12 --- .../src/database/DatabaseDoraDeployments.ts | 2 +- .../database/DatabaseDoraPullRequests.test.ts | 1 - .../src/database/mappers.test.ts | 5 -- .../src/database/mappers.ts | 3 - .../src/database/types.ts | 2 - .../DoraChangeFailureRateProvider.test.ts | 16 +--- .../DoraChangeFailureRateProvider.ts | 19 +++-- .../DoraDeploymentFrequencyProvider.test.ts | 26 +------ .../DoraDeploymentFrequencyProvider.ts | 6 +- ...raMedianLeadTimeForChangesProvider.test.ts | 10 --- .../DoraMedianLeadTimeForChangesProvider.ts | 6 +- .../__fixtures__/mockDoraStores.ts | 2 - .../utils/deploymentFilterUtils.test.ts | 28 +------ .../utils/deploymentFilterUtils.ts | 16 +--- .../src/service/DoraDataService.test.ts | 3 - .../src/service/DoraSyncService.test.ts | 74 +++++++++++++++++++ .../src/service/DoraSyncService.ts | 19 ++--- 22 files changed, 116 insertions(+), 145 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/docs/metrics/change-failure-rate.md b/workspaces/scorecard/plugins/scorecard-backend-module-dora/docs/metrics/change-failure-rate.md index b5baef580a0..41ad6141aa7 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/docs/metrics/change-failure-rate.md +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/docs/metrics/change-failure-rate.md @@ -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. diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/docs/metrics/deployment-frequency.md b/workspaces/scorecard/plugins/scorecard-backend-module-dora/docs/metrics/deployment-frequency.md index d63c8633c69..f5d28f8a554 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/docs/metrics/deployment-frequency.md +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/docs/metrics/deployment-frequency.md @@ -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) diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/docs/metrics/median-lead-time-for-changes.md b/workspaces/scorecard/plugins/scorecard-backend-module-dora/docs/metrics/median-lead-time-for-changes.md index 5d0efa400c4..7f5e9cc98fb 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/docs/metrics/median-lead-time-for-changes.md +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/docs/metrics/median-lead-time-for-changes.md @@ -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)`. @@ -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. diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/migrations/20260723000000_init.js b/workspaces/scorecard/plugins/scorecard-backend-module-dora/migrations/20260723000000_init.js index e31573eb507..a93375ae73f 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/migrations/20260723000000_init.js +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/migrations/20260723000000_init.js @@ -23,7 +23,6 @@ exports.up = async function up(knex) { table.string('commit_sha').notNullable(); table.string('environment').nullable(); table.dateTime('created_at', { precision: 0 }).notNullable(); - table.string('result').notNullable(); table.unique([ 'catalog_entity_ref', diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.test.ts index 48919d00609..31c262654d9 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.test.ts @@ -42,7 +42,6 @@ describe('DatabaseDoraDeployments', () => { commitSha: 'sha-1', environment: 'production', createdAt: new Date('2026-06-01T10:00:00.000Z'), - result: 'success', }, ]); @@ -77,7 +76,6 @@ describe('DatabaseDoraDeployments', () => { commitSha: 'sha-1', environment: 'production', createdAt: new Date('2026-06-10T10:00:00.000Z'), - result: 'success', }, ]); // Conflict on (catalog_entity_ref, collector_id, original_deployment_id) for commitSha @@ -89,7 +87,6 @@ describe('DatabaseDoraDeployments', () => { commitSha: 'sha-1-updated', environment: 'production', createdAt: new Date('2026-06-10T10:00:00.000Z'), - result: 'failure', }, ]); @@ -102,7 +99,6 @@ describe('DatabaseDoraDeployments', () => { expect(rows).toHaveLength(1); expect(rows[0].commitSha).toBe('sha-1-updated'); - expect(rows[0].result).toBe('failure'); }, ); @@ -122,7 +118,6 @@ describe('DatabaseDoraDeployments', () => { commitSha: 'sha-1', environment: 'production', createdAt: new Date('2026-06-10T10:00:00.000Z'), - result: 'success', }, { catalogEntityRef: entityRef, @@ -131,7 +126,6 @@ describe('DatabaseDoraDeployments', () => { commitSha: 'sha-other', environment: 'production', createdAt: new Date('2026-06-20T10:00:00.000Z'), - result: 'success', }, ]); @@ -184,7 +178,6 @@ describe('DatabaseDoraDeployments', () => { commitSha: 'sha-before', environment: 'production', createdAt: new Date('2026-05-31T10:00:00.000Z'), - result: 'success', }, { catalogEntityRef: entityRef, @@ -193,7 +186,6 @@ describe('DatabaseDoraDeployments', () => { commitSha: 'sha-1', environment: 'production', createdAt: new Date('2026-06-01T10:00:00.000Z'), - result: 'success', }, { catalogEntityRef: entityRef, @@ -202,7 +194,6 @@ describe('DatabaseDoraDeployments', () => { commitSha: 'sha-2', environment: 'production', createdAt: new Date('2026-06-10T10:00:00.000Z'), - result: 'success', }, { catalogEntityRef: entityRef, @@ -211,7 +202,6 @@ describe('DatabaseDoraDeployments', () => { commitSha: 'sha-other', environment: 'production', createdAt: new Date('2026-06-15T10:00:00.000Z'), - result: 'success', }, ]); @@ -248,7 +238,6 @@ describe('DatabaseDoraDeployments', () => { commitSha: 'sha-old', environment: 'production', createdAt: new Date('2025-01-01T00:00:00.000Z'), - result: 'success', }, { catalogEntityRef: entityRef, @@ -257,7 +246,6 @@ describe('DatabaseDoraDeployments', () => { commitSha: 'sha-new', environment: 'production', createdAt: new Date('2026-06-10T00:00:00.000Z'), - result: 'success', }, ]); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.ts index 33ffc55c97f..051f9152201 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.ts @@ -56,7 +56,7 @@ export class DatabaseDoraDeployments implements DoraDeploymentsStore { 'collector_id', 'original_deployment_id', ]) - .merge(['commit_sha', 'environment', 'created_at', 'result']); + .merge(['commit_sha', 'environment', 'created_at']); } async readByEntityCollectorAndWindow( diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.test.ts index 8d51a0eed1e..fcc9c0ce614 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.test.ts @@ -33,7 +33,6 @@ async function seedDeployment( commitSha: 'sha-1', environment: 'production', createdAt: new Date('2026-06-10T10:00:00.000Z'), - result: 'success', }, ]); const [deployment] = await deploymentsDb.readByEntityCollectorAndWindow( diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/mappers.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/mappers.test.ts index 38421cb0b06..35963e44d6f 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/mappers.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/mappers.test.ts @@ -60,7 +60,6 @@ describe('mappers', () => { commitSha: 'sha-1', environment: 'production', createdAt, - result: 'success', }; expect(toDoraDeploymentRow(create)).toEqual({ @@ -70,7 +69,6 @@ describe('mappers', () => { commit_sha: 'sha-1', environment: 'production', created_at: createdAt, - result: 'success', }); }); @@ -82,7 +80,6 @@ describe('mappers', () => { originalDeploymentId: 'dep-1', commitSha: 'sha-1', createdAt: new Date('2026-06-10T10:00:00.000Z'), - result: 'success', }).environment, ).toBeNull(); }); @@ -97,7 +94,6 @@ describe('mappers', () => { commit_sha: 'sha-1', environment: null, created_at: '2026-06-10T10:00:00.000Z', - result: 'success', }), ).toEqual({ id: 'dep-row-1', @@ -107,7 +103,6 @@ describe('mappers', () => { commitSha: 'sha-1', environment: null, createdAt: new Date('2026-06-10T10:00:00.000Z'), - result: 'success', }); }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/mappers.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/mappers.ts index 2d42a9878c7..3438b5b3938 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/mappers.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/mappers.ts @@ -31,7 +31,6 @@ export type DbDoraDeploymentRow = { commit_sha: string; environment: string | null; created_at: Date | string; - result: string; }; export type DbDoraIncidentRow = { @@ -63,7 +62,6 @@ export function toDoraDeploymentRow( commit_sha: deployment.commitSha, environment: deployment.environment ?? null, created_at: deployment.createdAt, - result: deployment.result, }; } @@ -78,7 +76,6 @@ export function fromDoraDeploymentRow( commitSha: row.commit_sha, environment: row.environment, createdAt: asDate(row.created_at), - result: row.result, }; } diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/types.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/types.ts index da3eef302db..5cfddd3d747 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/types.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/types.ts @@ -21,7 +21,6 @@ export type DbDoraDeploymentCreate = { commitSha: string; environment?: string | null; createdAt: Date; - result: string; }; export type DbDoraDeployment = { @@ -32,7 +31,6 @@ export type DbDoraDeployment = { commitSha: string; environment: string | null; createdAt: Date; - result: string; }; export type DbDoraIncidentCreate = { diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraChangeFailureRateProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraChangeFailureRateProvider.test.ts index 9be958e4bf4..ec1d76b6a63 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraChangeFailureRateProvider.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraChangeFailureRateProvider.test.ts @@ -42,14 +42,12 @@ describe('DoraChangeFailureRateProvider', () => { commitSha: 'sha-1', environment: 'production', createdAt: '2026-06-10T00:00:00.000Z', - result: 'success', }), dbDeployment({ id: '101', commitSha: 'sha-2', environment: 'production', createdAt: '2026-06-11T00:00:00.000Z', - result: 'success', }), ]); mockDoraDataService.readIncidents.mockResolvedValue([ @@ -172,21 +170,18 @@ describe('DoraChangeFailureRateProvider', () => { commitSha: 'sha-1', environment: 'production', createdAt: '2026-06-10T00:00:00.000Z', - result: 'success', }), dbDeployment({ id: '101', commitSha: 'sha-2', environment: 'production', createdAt: '2026-06-11T00:00:00.000Z', - result: 'success', }), dbDeployment({ id: '102', commitSha: 'sha-3', environment: 'production', createdAt: '2026-06-12T00:00:00.000Z', - result: 'success', }), ]); mockDoraDataService.readIncidents.mockResolvedValueOnce([ @@ -216,7 +211,6 @@ describe('DoraChangeFailureRateProvider', () => { commitSha: 'sha-1', environment: 'production', createdAt: '2026-06-10T00:00:00.000Z', - result: 'success', }), ]); @@ -225,21 +219,19 @@ describe('DoraChangeFailureRateProvider', () => { ); }); - it('should throw when fewer than 2 successful deployments are found', async () => { + it('should throw when fewer than 2 production deployments are found among mixed environments', async () => { mockDoraDataService.readDeployments.mockResolvedValueOnce([ dbDeployment({ id: '100', commitSha: 'sha-1', environment: 'production', createdAt: '2026-06-10T00:00:00.000Z', - result: 'success', }), dbDeployment({ id: '101', commitSha: 'sha-2', - environment: 'production', + environment: 'development', createdAt: '2026-06-11T00:00:00.000Z', - result: 'failure', }), ]); @@ -255,14 +247,12 @@ describe('DoraChangeFailureRateProvider', () => { commitSha: 'sha-1', environment: 'production', createdAt: '2026-06-10T00:00:00.000Z', - result: 'success', }), dbDeployment({ id: '101', commitSha: 'sha-2', environment: 'demo-test', createdAt: '2026-06-11T00:00:00.000Z', - result: 'success', }), ]); @@ -278,14 +268,12 @@ describe('DoraChangeFailureRateProvider', () => { commitSha: 'sha-1', environment: 'production', createdAt: '2026-06-10T00:00:00.000Z', - result: 'success', }), dbDeployment({ id: '101', commitSha: 'sha-2', environment: 'production', createdAt: '2026-06-10T00:00:00.000Z', - result: 'success', }), ]); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraChangeFailureRateProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraChangeFailureRateProvider.ts index bfadf307b77..5ea244d4dd6 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraChangeFailureRateProvider.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraChangeFailureRateProvider.ts @@ -28,7 +28,7 @@ import { type DoraChangeFailureRateConfig, parseDoraChangeFailureRateConfig, } from './DoraConfig'; -import { isSuccessfulProductionDeployment } from './utils/deploymentFilterUtils'; +import { isProductionEnvironment } from './utils/deploymentFilterUtils'; type DoraChangeFailureRateProviderOptions = { doraSyncService: DoraSyncService; @@ -129,16 +129,16 @@ export class DoraChangeFailureRateProvider implements MetricProvider<'number'> { }), ]); - const successfulProductionDeployments = deployments.filter(deployment => - isSuccessfulProductionDeployment( - deployment, + const productionDeployments = deployments.filter(deployment => + isProductionEnvironment( + deployment.environment, this.config.productionEnvironments, ), ); - if (successfulProductionDeployments.length < 2) { + if (productionDeployments.length < 2) { throw new Error( - `Unable to calculate change failure rate: need at least 2 successful production deployments in the last ${DORA_TIME_WINDOW_DAYS} days, found ${successfulProductionDeployments.length}`, + `Unable to calculate change failure rate: need at least 2 successful production deployments in the last ${DORA_TIME_WINDOW_DAYS} days, found ${productionDeployments.length}`, ); } @@ -146,12 +146,11 @@ export class DoraChangeFailureRateProvider implements MetricProvider<'number'> { let evaluatedDeployments = 0; for ( let deploymentIndex = 0; - deploymentIndex < successfulProductionDeployments.length - 1; + deploymentIndex < productionDeployments.length - 1; deploymentIndex++ ) { - const deployment = successfulProductionDeployments[deploymentIndex]; - const nextDeployment = - successfulProductionDeployments[deploymentIndex + 1]; + const deployment = productionDeployments[deploymentIndex]; + const nextDeployment = productionDeployments[deploymentIndex + 1]; const deploymentCreatedAt = deployment.createdAt.getTime(); const nextDeploymentCreatedAt = nextDeployment.createdAt.getTime(); if (nextDeploymentCreatedAt <= deploymentCreatedAt) { diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraDeploymentFrequencyProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraDeploymentFrequencyProvider.test.ts index 0fe361164d4..3343add9336 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraDeploymentFrequencyProvider.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraDeploymentFrequencyProvider.test.ts @@ -120,47 +120,30 @@ describe('DoraDeploymentFrequencyProvider', () => { ); }); - it('should calculate frequency for success result and production environment', async () => { + it('should calculate frequency for production environments only', async () => { mockDoraDataService.readDeployments.mockResolvedValueOnce([ dbDeployment({ id: '100', commitSha: 'sha-1', environment: 'production', createdAt: '2026-06-01T10:00:00.000Z', - result: 'success', }), dbDeployment({ id: '101', commitSha: 'sha-2', - environment: 'production', - createdAt: '2026-06-02T10:00:00.000Z', - result: 'failure', + createdAt: '2026-06-04T10:00:00.000Z', }), dbDeployment({ id: '102', commitSha: 'sha-3', - environment: 'production', - createdAt: '2026-06-03T10:00:00.000Z', - result: '', - }), - dbDeployment({ - id: '103', - commitSha: 'sha-2', - createdAt: '2026-06-04T10:00:00.000Z', - result: 'success', - }), - dbDeployment({ - id: '104', - commitSha: 'sha-4', environment: 'development', createdAt: '2026-06-04T11:00:00.000Z', - result: 'success', }), ]); const results = await provider.calculateMetrics(mockEntity); - expect(results.get('dora.deploymentFrequency')).toBe(0.4667); // (2 successful deployments / 30 days) * 7 + expect(results.get('dora.deploymentFrequency')).toBe(0.4667); // (2 production deployments / 30 days) * 7 }); it('returns 0 when no deployments are collected', async () => { @@ -178,21 +161,18 @@ describe('DoraDeploymentFrequencyProvider', () => { commitSha: 'sha-1', environment: 'prod', createdAt: '2026-06-01T10:00:00.000Z', - result: 'success', }), dbDeployment({ id: '101', commitSha: 'sha-2', environment: 'live', createdAt: '2026-06-02T10:00:00.000Z', - result: 'success', }), dbDeployment({ id: '102', commitSha: 'sha-3', environment: 'production', createdAt: '2026-06-03T10:00:00.000Z', - result: 'success', }), ]); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraDeploymentFrequencyProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraDeploymentFrequencyProvider.ts index 139373ee620..7a554a764e1 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraDeploymentFrequencyProvider.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraDeploymentFrequencyProvider.ts @@ -26,7 +26,7 @@ import { type DoraDeploymentFrequencyConfig, parseDoraDeploymentFrequencyConfig, } from './DoraConfig'; -import { isSuccessfulProductionDeployment } from './utils/deploymentFilterUtils'; +import { isProductionEnvironment } from './utils/deploymentFilterUtils'; type DoraDeploymentFrequencyProviderOptions = { doraSyncService: DoraSyncService; @@ -110,8 +110,8 @@ export class DoraDeploymentFrequencyProvider collector: this.config.deploymentsCollector, }) ).filter(deployment => - isSuccessfulProductionDeployment( - deployment, + isProductionEnvironment( + deployment.environment, this.config.productionEnvironments, ), ); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMedianLeadTimeForChangesProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMedianLeadTimeForChangesProvider.test.ts index 2f94e5d23bc..cf1fc70b2eb 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMedianLeadTimeForChangesProvider.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMedianLeadTimeForChangesProvider.test.ts @@ -38,14 +38,12 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { commitSha: 'sha-previous', environment: 'production', createdAt: '2026-06-06T12:00:00.000Z', - result: 'success', }), dbDeployment({ id: '101', commitSha: 'sha-current', environment: 'production', createdAt: '2026-06-08T12:00:00.000Z', - result: 'success', }), ]; const pullRequests = [ @@ -207,21 +205,18 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { commitSha: 'sha-1', environment: 'production', createdAt: '2026-06-10T00:00:00.000Z', - result: 'success', }), dbDeployment({ id: '401', commitSha: 'sha-2', environment: 'production', createdAt: '2026-06-11T00:00:00.000Z', - result: 'success', }), dbDeployment({ id: '402', commitSha: 'sha-3', environment: 'production', createdAt: '2026-06-12T00:00:00.000Z', - result: 'success', }), ]); mockDoraDataService.readPullRequestsForDeployment @@ -293,14 +288,12 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { commitSha: 'sha-1', environment: 'production', createdAt: '2026-06-10T00:00:00.000Z', - result: 'success', }), dbDeployment({ id: '401', commitSha: 'sha-2', environment: 'prod', createdAt: '2026-06-11T00:00:00.000Z', - result: 'success', }), ]); @@ -345,21 +338,18 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { commitSha: 'sha-1', environment: 'production', createdAt: '2026-06-10T00:00:00.000Z', - result: 'success', }), dbDeployment({ id: '101', commitSha: 'sha-2', environment: 'production', createdAt: '2026-06-11T00:00:00.000Z', - result: 'success', }), dbDeployment({ id: '102', commitSha: 'sha-3', environment: 'production', createdAt: '2026-06-12T00:00:00.000Z', - result: 'success', }), ]); mockDoraSyncService.syncPullRequestsForDeployment diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMedianLeadTimeForChangesProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMedianLeadTimeForChangesProvider.ts index 6d578b4070a..cb75c85f4b4 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMedianLeadTimeForChangesProvider.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMedianLeadTimeForChangesProvider.ts @@ -29,7 +29,7 @@ import { type DoraMedianLeadTimeForChangesConfig, } from './DoraConfig'; import { calculateMedian } from './utils/calculationUtils'; -import { isSuccessfulProductionDeployment } from './utils/deploymentFilterUtils'; +import { isProductionEnvironment } from './utils/deploymentFilterUtils'; type DoraMedianLeadTimeForChangesProviderOptions = { doraSyncService: DoraSyncService; @@ -121,8 +121,8 @@ export class DoraMedianLeadTimeForChangesProvider collector: this.config.deploymentsCollector, }) ).filter(deployment => - isSuccessfulProductionDeployment( - deployment, + isProductionEnvironment( + deployment.environment, this.config.productionEnvironments, ), ); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockDoraStores.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockDoraStores.ts index c4a2a94e58c..123eed9101d 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockDoraStores.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockDoraStores.ts @@ -31,7 +31,6 @@ export function dbDeployment(partial: { commitSha: string; environment?: string | null; createdAt: string | Date; - result: string; catalogEntityRef?: string; collectorId?: string; originalDeploymentId?: string; @@ -44,7 +43,6 @@ export function dbDeployment(partial: { commitSha: partial.commitSha, environment: partial.environment ?? null, createdAt: asDate(partial.createdAt), - result: partial.result, }; } diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/utils/deploymentFilterUtils.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/utils/deploymentFilterUtils.test.ts index 33c86e7ead9..b1ec2739e40 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/utils/deploymentFilterUtils.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/utils/deploymentFilterUtils.test.ts @@ -14,10 +14,7 @@ * limitations under the License. */ -import { - isProductionEnvironment, - isSuccessfulProductionDeployment, -} from './deploymentFilterUtils'; +import { isProductionEnvironment } from './deploymentFilterUtils'; describe('deploymentFilterUtils', () => { describe('isProductionEnvironment', () => { @@ -35,27 +32,4 @@ describe('deploymentFilterUtils', () => { ); }); }); - - describe('isSuccessfulProductionDeployment', () => { - it('requires success and a production environment', () => { - expect( - isSuccessfulProductionDeployment( - { result: 'success', environment: 'production' }, - ['production'], - ), - ).toBe(true); - expect( - isSuccessfulProductionDeployment( - { result: 'failure', environment: 'production' }, - ['production'], - ), - ).toBe(false); - expect( - isSuccessfulProductionDeployment( - { result: 'success', environment: 'development' }, - ['production'], - ), - ).toBe(false); - }); - }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/utils/deploymentFilterUtils.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/utils/deploymentFilterUtils.ts index 128eb219bd4..f2b54fed436 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/utils/deploymentFilterUtils.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/utils/deploymentFilterUtils.ts @@ -17,6 +17,8 @@ /** * Missing/unknown environment is treated as production. Named environments must * match one of the configured production environment names (case-insensitive). + * + * Only successful deployments are persisted; callers filter by environment. */ export function isProductionEnvironment( environment: string | null | undefined, @@ -31,17 +33,3 @@ export function isProductionEnvironment( name => name.toLowerCase() === normalizedEnvironment, ); } - -export function isSuccessfulProductionDeployment( - deployment: { result: string; environment?: string | null }, - productionEnvironments: string[], -): boolean { - if (deployment.result !== 'success') { - return false; - } - - return isProductionEnvironment( - deployment.environment, - productionEnvironments, - ); -} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraDataService.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraDataService.test.ts index 4ceea6c76bc..01b818bf434 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraDataService.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraDataService.test.ts @@ -62,7 +62,6 @@ describe('DefaultDoraDataService', () => { commitSha: 'sha-1', environment: 'production', createdAt: new Date('2026-06-10T10:00:00.000Z'), - result: 'success', }, ]); @@ -81,7 +80,6 @@ describe('DefaultDoraDataService', () => { commitSha: 'sha-1', environment: 'production', createdAt: new Date('2026-06-10T10:00:00.000Z'), - result: 'success', }, ]); }, @@ -146,7 +144,6 @@ describe('DefaultDoraDataService', () => { commitSha: 'sha-1', environment: 'production', createdAt: new Date('2026-06-10T10:00:00.000Z'), - result: 'success', }, ]); const [deployment] = await deploymentsDb.readByEntityCollectorAndWindow( diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.test.ts index 545d3d31eef..5d9043a9519 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.test.ts @@ -155,6 +155,80 @@ describe('DefaultDoraSyncService', () => { }, ); + it.each(databases.eachSupportedId())( + 'persists only successful deployments - %p', + async databaseId => { + const { deployments, incidents, pullRequests, lastSync } = + await createTestDatabase(await databases.init(databaseId)); + + const deploymentsCollector = buildMockDeploymentsCollector({ + deployments: [ + { + id: '100', + commitSha: 'sha-success', + environment: 'production', + createdAt: '2026-06-10T00:00:00.000Z', + result: 'success', + }, + { + id: '101', + commitSha: 'sha-failure', + environment: 'production', + createdAt: '2026-06-11T00:00:00.000Z', + result: 'failure', + }, + { + id: '102', + commitSha: 'sha-empty', + environment: 'production', + createdAt: '2026-06-12T00:00:00.000Z', + result: '', + }, + ], + collectorId: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + }); + const { collectorsService } = buildMockCollectorsService({ + collectors: [deploymentsCollector], + }); + + const syncService = new DefaultDoraSyncService( + collectorsService, + deployments, + incidents, + pullRequests, + lastSync, + logger, + ); + const dataService = new DefaultDoraDataService( + deployments, + incidents, + pullRequests, + ); + + await syncService.syncDeployments(mockEntity, { + windowFrom: new Date('2026-06-01T00:00:00.000Z'), + windowTo: new Date('2026-06-30T00:00:00.000Z'), + collector: { + id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + input: {}, + }, + }); + + await expect( + dataService.readDeployments(stringifyEntityRef(mockEntity), { + windowFrom: new Date('2026-06-01T00:00:00.000Z'), + windowTo: new Date('2026-06-30T00:00:00.000Z'), + collector: { id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID }, + }), + ).resolves.toEqual([ + expect.objectContaining({ + originalDeploymentId: '100', + commitSha: 'sha-success', + }), + ]); + }, + ); + it.each(databases.eachSupportedId())( 'coalesces concurrent deployment syncs for the same entity and collector - %p', async databaseId => { diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.ts index 6c668e87757..158e7bd3336 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.ts @@ -127,15 +127,16 @@ export class DefaultDoraSyncService implements DoraSyncService { }); await this.deploymentsDb.upsert( - collected.deployments.map(deployment => ({ - catalogEntityRef, - collectorId, - originalDeploymentId: deployment.id, - commitSha: deployment.commitSha, - environment: deployment.environment ?? null, - createdAt: new Date(deployment.createdAt), - result: deployment.result, - })), + collected.deployments + .filter(deployment => deployment.result === 'success') + .map(deployment => ({ + catalogEntityRef, + collectorId, + originalDeploymentId: deployment.id, + commitSha: deployment.commitSha, + environment: deployment.environment ?? null, + createdAt: new Date(deployment.createdAt), + })), ); await this.lastSyncDb.setLastSyncedAt( From b08dcdc65e4cdb909ab1faba7bb84d2f39000393 Mon Sep 17 00:00:00 2001 From: Dominika Zemanovicova Date: Fri, 14 Aug 2026 17:41:19 +0200 Subject: [PATCH 10/18] Vlidate dataRetentionDays are older than dora window Co-authored-by: Cursor Signed-off-by: Dominika Zemanovicova --- .../scorecard-backend-module-dora/README.md | 2 +- .../scorecard-backend-module-dora/config.d.ts | 1 + .../src/metricProviders/DoraConfig.test.ts | 37 ++++++++++++++++++- .../src/metricProviders/DoraConfig.ts | 14 +++++-- .../src/module.ts | 7 +++- .../scheduler/CleanupExpiredDataTask.test.ts | 24 ++---------- .../src/scheduler/CleanupExpiredDataTask.ts | 13 +++---- 7 files changed, 63 insertions(+), 35 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/README.md b/workspaces/scorecard/plugins/scorecard-backend-module-dora/README.md index ec07795d651..cd95a677a65 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/README.md +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/README.md @@ -175,5 +175,5 @@ scorecard: staleAfterMs: 60000 ``` -- `dataRetentionDays`: how long source rows are retained before cleanup. +- `dataRetentionDays`: how long source rows are retained before cleanup. Must be at least `30` (the DORA metric computation window). - `staleAfterMs`: freshness threshold in milliseconds for deployments and incidents; if the last sync is within this window, those collectors are not refreshed. diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/config.d.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/config.d.ts index 88ebc506408..0cfdf0ed293 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/config.d.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/config.d.ts @@ -32,6 +32,7 @@ export interface Config { * 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; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraConfig.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraConfig.test.ts index 309178fc992..8112c78dc26 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraConfig.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraConfig.test.ts @@ -21,6 +21,8 @@ import { DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, DORA_DEFAULT_PRODUCTION_ENVIRONMENTS, + DORA_DEFAULT_STALE_AFTER_MS, + DORA_TIME_WINDOW_DAYS, } from '../constants'; import { parseDoraChangeFailureRateConfig, @@ -30,7 +32,6 @@ import { parseDoraMedianLeadTimeForChangesConfig, parseDoraStaleAfterMs, } from './DoraConfig'; -import { DORA_DEFAULT_STALE_AFTER_MS } from '../constants'; describe('DoraConfig', () => { describe('parseDoraDeploymentFrequencyConfig', () => { @@ -274,6 +275,40 @@ describe('DoraConfig', () => { ), ).toBe(90); }); + + it('throws when configured below the DORA metric window', () => { + expect(() => + parseDoraDataRetentionDays( + new ConfigReader({ + scorecard: { + plugins: { + dora: { + dataRetentionDays: DORA_TIME_WINDOW_DAYS - 1, + }, + }, + }, + }), + ), + ).toThrow( + `scorecard.plugins.dora.dataRetentionDays must be greater than or equal to ${DORA_TIME_WINDOW_DAYS}`, + ); + }); + + it('allows retention equal to the DORA metric window', () => { + expect( + parseDoraDataRetentionDays( + new ConfigReader({ + scorecard: { + plugins: { + dora: { + dataRetentionDays: DORA_TIME_WINDOW_DAYS, + }, + }, + }, + }), + ), + ).toBe(DORA_TIME_WINDOW_DAYS); + }); }); describe('parseDoraStaleAfterMs', () => { diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraConfig.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraConfig.ts index 7f514c78ecf..73b6a44bbcd 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraConfig.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraConfig.ts @@ -27,6 +27,7 @@ import { DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, DORA_DEFAULT_PRODUCTION_ENVIRONMENTS, DORA_DEFAULT_STALE_AFTER_MS, + DORA_TIME_WINDOW_DAYS, } from '../constants'; import type { JsonValue } from '@backstage/types'; @@ -274,12 +275,19 @@ export function parseDoraChangeFailureRateConfig( /** * Parses DORA source-data retention days from the root Backstage config. + * Must be at least the DORA metric computation window so cleanup cannot delete + * in-window rows that incremental sync will not backfill. */ export function parseDoraDataRetentionDays(config: Config): number { - return ( + const dataRetentionDays = config.getOptionalNumber('scorecard.plugins.dora.dataRetentionDays') ?? - DORA_DEFAULT_DATA_RETENTION_DAYS - ); + DORA_DEFAULT_DATA_RETENTION_DAYS; + if (dataRetentionDays < DORA_TIME_WINDOW_DAYS) { + throw new Error( + `scorecard.plugins.dora.dataRetentionDays must be greater than or equal to ${DORA_TIME_WINDOW_DAYS}`, + ); + } + return dataRetentionDays; } /** diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts index 7bce6ffe308..e9c11d0a5c3 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts @@ -33,7 +33,10 @@ import { DoraMeanTimeToRestoreProvider } from './metricProviders/DoraMeanTimeToR import { DefaultDoraDataService } from './service/DoraDataService'; import { DefaultDoraSyncService } from './service/DoraSyncService'; import { CleanupExpiredDataTask } from './scheduler/CleanupExpiredDataTask'; -import { parseDoraStaleAfterMs } from './metricProviders/DoraConfig'; +import { + parseDoraDataRetentionDays, + parseDoraStaleAfterMs, +} from './metricProviders/DoraConfig'; export const scorecardModuleDora = createBackendModule({ pluginId: 'scorecard', @@ -104,7 +107,7 @@ export const scorecardModuleDora = createBackendModule({ await new CleanupExpiredDataTask({ scheduler, logger, - config, + dataRetentionDays: parseDoraDataRetentionDays(config), deployments: deploymentsDb, incidents: incidentsDb, pullRequests: pullRequestsDb, diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/CleanupExpiredDataTask.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/CleanupExpiredDataTask.test.ts index ad82f3d4a43..82c2cd96498 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/CleanupExpiredDataTask.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/CleanupExpiredDataTask.test.ts @@ -20,9 +20,7 @@ import { SchedulerServiceTaskRunner, } from '@backstage/backend-plugin-api'; import { mockServices } from '@backstage/backend-test-utils'; -import type { Config } from '@backstage/config'; import { DORA_CLEANUP_EXPIRED_DATA_TASK_ID } from '../constants'; -import { parseDoraDataRetentionDays } from '../metricProviders/DoraConfig'; import { mockDoraDeploymentsStore, mockDoraIncidentsStore, @@ -30,14 +28,9 @@ import { } from '../metricProviders/__fixtures__'; import { CleanupExpiredDataTask } from './CleanupExpiredDataTask'; -jest.mock('../metricProviders/DoraConfig', () => ({ - parseDoraDataRetentionDays: jest.fn(), -})); - describe('CleanupExpiredDataTask', () => { let mockScheduler: jest.Mocked; let mockLogger: jest.Mocked; - let mockConfig: Config; let mockTaskRunner: jest.Mocked>; let task: CleanupExpiredDataTask; @@ -48,7 +41,6 @@ describe('CleanupExpiredDataTask', () => { mockScheduler = mockServices.scheduler.mock(); mockLogger = mockServices.logger.mock(); - mockConfig = mockServices.rootConfig.mock(); mockDoraDeploymentsStore.deleteOlderThan.mockResolvedValue(0); mockDoraIncidentsStore.deleteOlderThan.mockResolvedValue(0); mockDoraPullRequestsStore.deleteOlderThan.mockResolvedValue(0); @@ -64,7 +56,7 @@ describe('CleanupExpiredDataTask', () => { task = new CleanupExpiredDataTask({ scheduler: mockScheduler, logger: mockLogger, - config: mockConfig, + dataRetentionDays: 30, deployments: mockDoraDeploymentsStore, incidents: mockDoraIncidentsStore, pullRequests: mockDoraPullRequestsStore, @@ -99,11 +91,7 @@ describe('CleanupExpiredDataTask', () => { }); describe('cleanupExpiredData', () => { - const mockParseDoraDataRetentionDays = - parseDoraDataRetentionDays as jest.Mock; - beforeEach(async () => { - mockParseDoraDataRetentionDays.mockReturnValue(2); mockDoraDeploymentsStore.deleteOlderThan.mockResolvedValue(3); mockDoraIncidentsStore.deleteOlderThan.mockResolvedValue(4); mockDoraPullRequestsStore.deleteOlderThan.mockResolvedValue(5); @@ -111,13 +99,9 @@ describe('CleanupExpiredDataTask', () => { await (task as any).cleanupExpiredData(mockLogger); }); - it('reads retention days from DoraConfig', () => { - expect(mockParseDoraDataRetentionDays).toHaveBeenCalledWith(mockConfig); - }); - it('deletes data older than the retention cutoff', () => { - // today is 2024-01-15T12:00:00.000Z, cutoff is 2 days - const expectedDate = new Date('2024-01-13T12:00:00.000Z'); + // today is 2024-01-15T12:00:00.000Z, cutoff is 30 days + const expectedDate = new Date('2023-12-16T12:00:00.000Z'); expect(mockDoraPullRequestsStore.deleteOlderThan).toHaveBeenCalledWith( expectedDate, ); @@ -131,7 +115,7 @@ describe('CleanupExpiredDataTask', () => { it('logs deleted counts', () => { expect(mockLogger.info).toHaveBeenCalledWith( - 'Deleted 3 deployments, 4 incidents, 5 pull requests older than 2 days', + 'Deleted 3 deployments, 4 incidents, 5 pull requests older than 30 days', ); }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/CleanupExpiredDataTask.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/CleanupExpiredDataTask.ts index 4e178b10194..8a0640a2cb7 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/CleanupExpiredDataTask.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/CleanupExpiredDataTask.ts @@ -19,28 +19,26 @@ import { SchedulerService, SchedulerServiceTaskScheduleDefinition, } from '@backstage/backend-plugin-api'; -import type { Config } from '@backstage/config'; import { randomUUID } from 'node:crypto'; import type { DoraDeploymentsStore } from '../database/DatabaseDoraDeployments'; import type { DoraIncidentsStore } from '../database/DatabaseDoraIncidents'; import type { DoraPullRequestsStore } from '../database/DatabaseDoraPullRequests'; -import { parseDoraDataRetentionDays } from '../metricProviders/DoraConfig'; import { DORA_CLEANUP_EXPIRED_DATA_TASK_ID } from '../constants'; import { daysToMilliseconds } from './utils'; type Options = { scheduler: SchedulerService; logger: LoggerService; - config: Config; + dataRetentionDays: number; deployments: DoraDeploymentsStore; incidents: DoraIncidentsStore; pullRequests: DoraPullRequestsStore; }; export class CleanupExpiredDataTask { - private readonly config: Config; private readonly logger: LoggerService; private readonly scheduler: SchedulerService; + private readonly dataRetentionDays: number; private readonly deployments: DoraDeploymentsStore; private readonly incidents: DoraIncidentsStore; private readonly pullRequests: DoraPullRequestsStore; @@ -53,9 +51,9 @@ export class CleanupExpiredDataTask { }; constructor(options: Options) { - this.config = options.config; this.logger = options.logger; this.scheduler = options.scheduler; + this.dataRetentionDays = options.dataRetentionDays; this.deployments = options.deployments; this.incidents = options.incidents; this.pullRequests = options.pullRequests; @@ -84,9 +82,8 @@ export class CleanupExpiredDataTask { } private async cleanupExpiredData(logger: LoggerService): Promise { - const dataRetentionDays = parseDoraDataRetentionDays(this.config); const olderThan = new Date( - Date.now() - daysToMilliseconds(dataRetentionDays), + Date.now() - daysToMilliseconds(this.dataRetentionDays), ); const deletedPullRequests = await this.pullRequests.deleteOlderThan( @@ -99,7 +96,7 @@ export class CleanupExpiredDataTask { logger.info( `Deleted ${deletedDeployments} deployments, ${deletedIncidents} incidents, ` + - `${deletedPullRequests} pull requests older than ${dataRetentionDays} days`, + `${deletedPullRequests} pull requests older than ${this.dataRetentionDays} days`, ); } } From 79474d366dd323afef710014f404958f37b00970 Mon Sep 17 00:00:00 2001 From: Dominika Zemanovicova Date: Fri, 14 Aug 2026 18:02:50 +0200 Subject: [PATCH 11/18] Bump precision Co-authored-by: Cursor Signed-off-by: Dominika Zemanovicova --- .../migrations/20260723000000_init.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/migrations/20260723000000_init.js b/workspaces/scorecard/plugins/scorecard-backend-module-dora/migrations/20260723000000_init.js index a93375ae73f..0f322cd8ab9 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/migrations/20260723000000_init.js +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/migrations/20260723000000_init.js @@ -22,7 +22,8 @@ exports.up = async function up(knex) { table.string('original_deployment_id').notNullable(); table.string('commit_sha').notNullable(); table.string('environment').nullable(); - table.dateTime('created_at', { precision: 0 }).notNullable(); + // Millisecond precision so same-second deployments stay distinct for CFR/lead time. + table.dateTime('created_at', { precision: 3 }).notNullable(); table.unique([ 'catalog_entity_ref', @@ -41,9 +42,9 @@ exports.up = async function up(knex) { table.string('catalog_entity_ref').notNullable(); table.string('collector_id').notNullable(); table.string('original_incident_id').notNullable(); - table.dateTime('created_at', { precision: 0 }).notNullable(); - table.dateTime('updated_at', { precision: 0 }).notNullable(); - table.dateTime('resolution_at', { precision: 0 }).nullable(); + 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', @@ -62,7 +63,7 @@ exports.up = async function up(knex) { table.string('catalog_entity_ref').notNullable(); table.string('collector_id').notNullable(); table.string('original_pr_id').notNullable(); - table.dateTime('first_commit_at', { precision: 0 }).notNullable(); + table.dateTime('first_commit_at', { precision: 3 }).notNullable(); table .string('deployment_id') .references('id') @@ -86,7 +87,7 @@ exports.up = async function up(knex) { 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: 0 }).notNullable(); + table.dateTime('last_synced_at', { precision: 3 }).notNullable(); table.primary(['catalog_entity_ref', 'collector_id']); }); From c3d66da0b8d4fe2cd481e90851b4416f471de9ea Mon Sep 17 00:00:00 2001 From: Dominika Zemanovicova Date: Fri, 14 Aug 2026 18:09:52 +0200 Subject: [PATCH 12/18] Make deploymentId FK not nullable Co-authored-by: Cursor Signed-off-by: Dominika Zemanovicova --- .../migrations/20260723000000_init.js | 2 +- .../src/database/mappers.test.ts | 15 ++------------- .../src/database/mappers.ts | 4 ++-- .../src/database/types.ts | 4 ++-- .../__fixtures__/mockDoraStores.ts | 4 ++-- 5 files changed, 9 insertions(+), 20 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/migrations/20260723000000_init.js b/workspaces/scorecard/plugins/scorecard-backend-module-dora/migrations/20260723000000_init.js index 0f322cd8ab9..f2a9c10e501 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/migrations/20260723000000_init.js +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/migrations/20260723000000_init.js @@ -69,7 +69,7 @@ exports.up = async function up(knex) { .references('id') .inTable('dora_deployments') .onDelete('CASCADE') - .nullable(); + .notNullable(); table.unique([ 'catalog_entity_ref', diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/mappers.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/mappers.test.ts index 35963e44d6f..bc3058d08e0 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/mappers.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/mappers.test.ts @@ -186,17 +186,6 @@ describe('mappers', () => { }); }); - it('defaults missing deploymentId to null', () => { - expect( - toDoraPullRequestRow({ - catalogEntityRef: 'component:default/service-a', - collectorId: 'github:deploymentPullRequests', - originalPrId: 'pr-1', - firstCommitAt: new Date('2026-06-09T10:00:00.000Z'), - }).deployment_id, - ).toBeNull(); - }); - it('maps database row to camelCase including id', () => { expect( fromDoraPullRequestRow({ @@ -205,7 +194,7 @@ describe('mappers', () => { collector_id: 'github:deploymentPullRequests', original_pr_id: 'pr-1', first_commit_at: '2026-06-09T10:00:00.000Z', - deployment_id: null, + deployment_id: 'dep-row-1', }), ).toEqual({ id: 'pr-row-1', @@ -213,7 +202,7 @@ describe('mappers', () => { collectorId: 'github:deploymentPullRequests', originalPrId: 'pr-1', firstCommitAt: new Date('2026-06-09T10:00:00.000Z'), - deploymentId: null, + deploymentId: 'dep-row-1', }); }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/mappers.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/mappers.ts index 3438b5b3938..8d519fd1172 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/mappers.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/mappers.ts @@ -49,7 +49,7 @@ export type DbDoraPullRequestRow = { collector_id: string; original_pr_id: string; first_commit_at: Date | string; - deployment_id: string | null; + deployment_id: string; }; export function toDoraDeploymentRow( @@ -112,7 +112,7 @@ export function toDoraPullRequestRow( collector_id: pullRequest.collectorId, original_pr_id: pullRequest.originalPrId, first_commit_at: pullRequest.firstCommitAt, - deployment_id: pullRequest.deploymentId ?? null, + deployment_id: pullRequest.deploymentId, }; } diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/types.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/types.ts index 5cfddd3d747..f8ea9e149f9 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/types.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/types.ts @@ -57,7 +57,7 @@ export type DbDoraPullRequestCreate = { collectorId: string; originalPrId: string; firstCommitAt: Date; - deploymentId?: string | null; + deploymentId: string; }; export type DbDoraPullRequest = { @@ -66,5 +66,5 @@ export type DbDoraPullRequest = { collectorId: string; originalPrId: string; firstCommitAt: Date; - deploymentId: string | null; + deploymentId: string; }; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockDoraStores.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockDoraStores.ts index 123eed9101d..9bc6e6703df 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockDoraStores.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockDoraStores.ts @@ -72,7 +72,7 @@ export function dbIncident(partial: { export function dbPullRequest(partial: { id: string; firstCommitAt: string | Date; - deploymentId?: string | null; + deploymentId: string; catalogEntityRef?: string; collectorId?: string; originalPrId?: string; @@ -83,7 +83,7 @@ export function dbPullRequest(partial: { collectorId: partial.collectorId ?? 'github:deploymentPullRequests', originalPrId: partial.originalPrId ?? partial.id, firstCommitAt: asDate(partial.firstCommitAt), - deploymentId: partial.deploymentId ?? null, + deploymentId: partial.deploymentId, }; } From b1b69d1bc58e6e10858271246b4458828be60a49 Mon Sep 17 00:00:00 2001 From: Dominika Zemanovicova Date: Fri, 14 Aug 2026 18:29:32 +0200 Subject: [PATCH 13/18] Test all inserted data Signed-off-by: Dominika Zemanovicova --- .../src/database/DatabaseDoraDeployments.test.ts | 15 +++++++++++---- .../src/database/DatabaseDoraIncidents.test.ts | 15 +++++++++++---- .../src/database/DatabaseDoraPullRequests.test.ts | 14 ++++++++++---- 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.test.ts index 31c262654d9..5f417c959d4 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.test.ts @@ -52,10 +52,17 @@ describe('DatabaseDoraDeployments', () => { new Date('2026-06-30T00:00:00.000Z'), ); - expect(rows).toHaveLength(1); - expect(rows[0].id).toEqual(expect.any(String)); - expect(rows[0].originalDeploymentId).toBe('dep-1'); - expect(rows[0].commitSha).toBe('sha-1'); + expect(rows).toEqual([ + { + id: expect.any(String), + catalogEntityRef: entityRef, + collectorId, + originalDeploymentId: 'dep-1', + commitSha: 'sha-1', + environment: 'production', + createdAt: new Date('2026-06-01T10:00:00.000Z'), + }, + ]); }, ); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.test.ts index 7efba028ab6..310a23d61f6 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.test.ts @@ -52,10 +52,17 @@ describe('DatabaseDoraIncidents', () => { new Date('2026-06-30T00:00:00.000Z'), ); - expect(rows).toHaveLength(1); - expect(rows[0].id).toEqual(expect.any(String)); - expect(rows[0].originalIncidentId).toBe('INC-1'); - expect(rows[0].resolutionAt).toBeNull(); + expect(rows).toEqual([ + { + id: expect.any(String), + catalogEntityRef: entityRef, + collectorId, + originalIncidentId: 'INC-1', + createdAt: new Date('2026-06-01T10:00:00.000Z'), + updatedAt: new Date('2026-06-01T10:00:00.000Z'), + resolutionAt: null, + }, + ]); }, ); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.test.ts index fcc9c0ce614..e134e4f73a5 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.test.ts @@ -75,10 +75,16 @@ describe('DatabaseDoraPullRequests', () => { deployment.id, ); - expect(rows).toHaveLength(1); - expect(rows[0].id).toEqual(expect.any(String)); - expect(rows[0].originalPrId).toBe('pr-1'); - expect(rows[0].deploymentId).toBe(deployment.id); + expect(rows).toEqual([ + { + id: expect.any(String), + catalogEntityRef: entityRef, + collectorId: prCollectorId, + originalPrId: 'pr-1', + firstCommitAt: new Date('2026-06-09T10:00:00.000Z'), + deploymentId: deployment.id, + }, + ]); }, ); From 0c43921b724c5a205dc54ab6d4fef23da1a99870 Mon Sep 17 00:00:00 2001 From: Dominika Zemanovicova Date: Fri, 14 Aug 2026 18:38:25 +0200 Subject: [PATCH 14/18] Add changeset Co-authored-by: Cursor Signed-off-by: Dominika Zemanovicova --- workspaces/scorecard/.changeset/evil-turtles-return.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 workspaces/scorecard/.changeset/evil-turtles-return.md diff --git a/workspaces/scorecard/.changeset/evil-turtles-return.md b/workspaces/scorecard/.changeset/evil-turtles-return.md new file mode 100644 index 00000000000..1e2997ac2f3 --- /dev/null +++ b/workspaces/scorecard/.changeset/evil-turtles-return.md @@ -0,0 +1,6 @@ +--- +'@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. From 7ffd87f4368a26b81bc0bbed7f6d762e03e42bdf Mon Sep 17 00:00:00 2001 From: Dominika Zemanovicova Date: Mon, 17 Aug 2026 10:49:59 +0200 Subject: [PATCH 15/18] Update docs Co-authored-by: Cursor Signed-off-by: Dominika Zemanovicova --- workspaces/scorecard/.changeset/evil-turtles-return.md | 2 ++ .../plugins/scorecard-backend-module-dora/README.md | 4 +++- .../docs/metrics/change-failure-rate.md | 6 +++++- .../docs/metrics/mean-time-to-restore.md | 5 +++-- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/workspaces/scorecard/.changeset/evil-turtles-return.md b/workspaces/scorecard/.changeset/evil-turtles-return.md index 1e2997ac2f3..0ce6bab08d2 100644 --- a/workspaces/scorecard/.changeset/evil-turtles-return.md +++ b/workspaces/scorecard/.changeset/evil-turtles-return.md @@ -4,3 +4,5 @@ --- 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. diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/README.md b/workspaces/scorecard/plugins/scorecard-backend-module-dora/README.md index cd95a677a65..a0d957d32de 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/README.md +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/README.md @@ -175,5 +175,7 @@ scorecard: staleAfterMs: 60000 ``` -- `dataRetentionDays`: how long source rows are retained before cleanup. Must be at least `30` (the DORA metric computation window). +- `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, incidents, and pull requests older than `dataRetentionDays`. diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/docs/metrics/change-failure-rate.md b/workspaces/scorecard/plugins/scorecard-backend-module-dora/docs/metrics/change-failure-rate.md index 41ad6141aa7..bc825e8cf77 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/docs/metrics/change-failure-rate.md +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/docs/metrics/change-failure-rate.md @@ -130,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. diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/docs/metrics/mean-time-to-restore.md b/workspaces/scorecard/plugins/scorecard-backend-module-dora/docs/metrics/mean-time-to-restore.md index 7da1c297d8c..5ec4857812e 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/docs/metrics/mean-time-to-restore.md +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/docs/metrics/mean-time-to-restore.md @@ -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. From 4459ba87dba5f140f9c4000b0d4656aa355ac2a3 Mon Sep 17 00:00:00 2001 From: Dominika Zemanovicova Date: Mon, 17 Aug 2026 11:05:43 +0200 Subject: [PATCH 16/18] Delete PRs on cascade Co-authored-by: Cursor Signed-off-by: Dominika Zemanovicova --- .../scorecard-backend-module-dora/README.md | 2 +- .../database/DatabaseDoraPullRequests.test.ts | 42 ------------------- .../src/database/DatabaseDoraPullRequests.ts | 7 ---- .../__fixtures__/mockDoraStores.ts | 1 - .../src/module.ts | 1 - .../scheduler/CleanupExpiredDataTask.test.ts | 9 +--- .../src/scheduler/CleanupExpiredDataTask.ts | 11 +---- 7 files changed, 4 insertions(+), 69 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/README.md b/workspaces/scorecard/plugins/scorecard-backend-module-dora/README.md index a0d957d32de..959876424ce 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/README.md +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/README.md @@ -178,4 +178,4 @@ scorecard: - `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, incidents, and pull requests older than `dataRetentionDays`. +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. diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.test.ts index e134e4f73a5..0f9feb00c4e 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.test.ts @@ -178,46 +178,4 @@ describe('DatabaseDoraPullRequests', () => { }, ); }); - - describe('deleteOlderThan', () => { - it.each(databases.eachSupportedId())( - 'deletes pull requests with first commit before the cutoff - %p', - async databaseId => { - const { deployments, pullRequests } = await createTestDatabase( - await databases.init(databaseId), - ); - const { entityRef, deployment } = await seedDeployment(deployments); - const prCollectorId = 'github:deploymentRangePullRequests'; - - await pullRequests.upsert([ - { - catalogEntityRef: entityRef, - collectorId: prCollectorId, - originalPrId: 'pr-old', - firstCommitAt: new Date('2025-01-01T00:00:00.000Z'), - deploymentId: deployment.id, - }, - { - catalogEntityRef: entityRef, - collectorId: prCollectorId, - originalPrId: 'pr-new', - firstCommitAt: new Date('2026-06-09T10:00:00.000Z'), - deploymentId: deployment.id, - }, - ]); - - const deleted = await pullRequests.deleteOlderThan( - new Date('2026-01-01T00:00:00.000Z'), - ); - const remaining = await pullRequests.readByEntityCollectorAndDeployment( - entityRef, - prCollectorId, - deployment.id, - ); - - expect(deleted).toBe(1); - expect(remaining.map(row => row.originalPrId)).toEqual(['pr-new']); - }, - ); - }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.ts index 0aa41841807..dcf6b643e5f 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.ts @@ -30,7 +30,6 @@ export interface DoraPullRequestsStore { collectorId: string, deploymentId: string, ): Promise; - deleteOlderThan(olderThan: Date): Promise; } export class DatabaseDoraPullRequests implements DoraPullRequestsStore { @@ -73,10 +72,4 @@ export class DatabaseDoraPullRequests implements DoraPullRequestsStore { return rows.map(fromDoraPullRequestRow); } - - async deleteOlderThan(olderThan: Date): Promise { - return await this.dbClient(this.tableName) - .where('first_commit_at', '<', olderThan) - .del(); - } } diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockDoraStores.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockDoraStores.ts index 9bc6e6703df..18d4d8b8fd9 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockDoraStores.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockDoraStores.ts @@ -102,5 +102,4 @@ export const mockDoraIncidentsStore: jest.Mocked = { export const mockDoraPullRequestsStore: jest.Mocked = { upsert: jest.fn(), readByEntityCollectorAndDeployment: jest.fn(), - deleteOlderThan: jest.fn(), }; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts index e9c11d0a5c3..15509c72579 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts @@ -110,7 +110,6 @@ export const scorecardModuleDora = createBackendModule({ dataRetentionDays: parseDoraDataRetentionDays(config), deployments: deploymentsDb, incidents: incidentsDb, - pullRequests: pullRequestsDb, }).start(); }, }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/CleanupExpiredDataTask.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/CleanupExpiredDataTask.test.ts index 82c2cd96498..83165f46370 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/CleanupExpiredDataTask.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/CleanupExpiredDataTask.test.ts @@ -24,7 +24,6 @@ import { DORA_CLEANUP_EXPIRED_DATA_TASK_ID } from '../constants'; import { mockDoraDeploymentsStore, mockDoraIncidentsStore, - mockDoraPullRequestsStore, } from '../metricProviders/__fixtures__'; import { CleanupExpiredDataTask } from './CleanupExpiredDataTask'; @@ -43,7 +42,6 @@ describe('CleanupExpiredDataTask', () => { mockLogger = mockServices.logger.mock(); mockDoraDeploymentsStore.deleteOlderThan.mockResolvedValue(0); mockDoraIncidentsStore.deleteOlderThan.mockResolvedValue(0); - mockDoraPullRequestsStore.deleteOlderThan.mockResolvedValue(0); mockTaskRunner = { run: jest.fn().mockResolvedValue(undefined), @@ -59,7 +57,6 @@ describe('CleanupExpiredDataTask', () => { dataRetentionDays: 30, deployments: mockDoraDeploymentsStore, incidents: mockDoraIncidentsStore, - pullRequests: mockDoraPullRequestsStore, }); }); @@ -94,7 +91,6 @@ describe('CleanupExpiredDataTask', () => { beforeEach(async () => { mockDoraDeploymentsStore.deleteOlderThan.mockResolvedValue(3); mockDoraIncidentsStore.deleteOlderThan.mockResolvedValue(4); - mockDoraPullRequestsStore.deleteOlderThan.mockResolvedValue(5); await (task as any).cleanupExpiredData(mockLogger); }); @@ -102,9 +98,6 @@ describe('CleanupExpiredDataTask', () => { it('deletes data older than the retention cutoff', () => { // today is 2024-01-15T12:00:00.000Z, cutoff is 30 days const expectedDate = new Date('2023-12-16T12:00:00.000Z'); - expect(mockDoraPullRequestsStore.deleteOlderThan).toHaveBeenCalledWith( - expectedDate, - ); expect(mockDoraDeploymentsStore.deleteOlderThan).toHaveBeenCalledWith( expectedDate, ); @@ -115,7 +108,7 @@ describe('CleanupExpiredDataTask', () => { it('logs deleted counts', () => { expect(mockLogger.info).toHaveBeenCalledWith( - 'Deleted 3 deployments, 4 incidents, 5 pull requests older than 30 days', + 'Deleted 3 deployments and 4 incidents older than 30 days', ); }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/CleanupExpiredDataTask.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/CleanupExpiredDataTask.ts index 8a0640a2cb7..515b6031126 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/CleanupExpiredDataTask.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/CleanupExpiredDataTask.ts @@ -22,7 +22,6 @@ import { import { randomUUID } from 'node:crypto'; import type { DoraDeploymentsStore } from '../database/DatabaseDoraDeployments'; import type { DoraIncidentsStore } from '../database/DatabaseDoraIncidents'; -import type { DoraPullRequestsStore } from '../database/DatabaseDoraPullRequests'; import { DORA_CLEANUP_EXPIRED_DATA_TASK_ID } from '../constants'; import { daysToMilliseconds } from './utils'; @@ -32,7 +31,6 @@ type Options = { dataRetentionDays: number; deployments: DoraDeploymentsStore; incidents: DoraIncidentsStore; - pullRequests: DoraPullRequestsStore; }; export class CleanupExpiredDataTask { @@ -41,7 +39,6 @@ export class CleanupExpiredDataTask { private readonly dataRetentionDays: number; private readonly deployments: DoraDeploymentsStore; private readonly incidents: DoraIncidentsStore; - private readonly pullRequests: DoraPullRequestsStore; private static readonly CLEANUP_SCHEDULE: SchedulerServiceTaskScheduleDefinition = { @@ -56,7 +53,6 @@ export class CleanupExpiredDataTask { this.dataRetentionDays = options.dataRetentionDays; this.deployments = options.deployments; this.incidents = options.incidents; - this.pullRequests = options.pullRequests; } async start(): Promise { @@ -86,17 +82,14 @@ export class CleanupExpiredDataTask { Date.now() - daysToMilliseconds(this.dataRetentionDays), ); - const deletedPullRequests = await this.pullRequests.deleteOlderThan( - olderThan, - ); + // Pull requests are removed via ON DELETE CASCADE when their deployment is deleted. const deletedDeployments = await this.deployments.deleteOlderThan( olderThan, ); const deletedIncidents = await this.incidents.deleteOlderThan(olderThan); logger.info( - `Deleted ${deletedDeployments} deployments, ${deletedIncidents} incidents, ` + - `${deletedPullRequests} pull requests older than ${this.dataRetentionDays} days`, + `Deleted ${deletedDeployments} deployments and ${deletedIncidents} incidents older than ${this.dataRetentionDays} days`, ); } } From 2cbe4c45ff2b9e101444952f60c3826f7c33a45a Mon Sep 17 00:00:00 2001 From: Dominika Zemanovicova Date: Mon, 17 Aug 2026 11:17:18 +0200 Subject: [PATCH 17/18] Update tests Co-authored-by: Cursor Signed-off-by: Dominika Zemanovicova --- .../DoraChangeFailureRateProvider.test.ts | 51 ++++ .../src/service/DoraSyncService.test.ts | 259 ++++++++++++++++++ 2 files changed, 310 insertions(+) diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraChangeFailureRateProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraChangeFailureRateProvider.test.ts index ec1d76b6a63..fa9cb57c599 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraChangeFailureRateProvider.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraChangeFailureRateProvider.test.ts @@ -204,6 +204,57 @@ describe('DoraChangeFailureRateProvider', () => { expect(results.get('dora.changeFailureRate')).toBe(50); // 1 failed pair out of 2 pairs }); + it('should return 0 when evaluated intervals have no incidents', async () => { + mockDoraDataService.readIncidents.mockResolvedValueOnce([]); + + const results = await provider.calculateMetrics(mockEntity); + + expect(results.get('dora.changeFailureRate')).toBe(0); + }); + + it('should attribute an incident after last successful production deployment to the following DORA interval', async () => { + mockDoraDataService.readDeployments.mockResolvedValueOnce([ + dbDeployment({ + id: '100', + commitSha: 'sha-1', + environment: 'production', + createdAt: '2026-06-10T00:00:00.000Z', + }), + dbDeployment({ + id: '101', + commitSha: 'sha-2', + environment: 'production', + createdAt: '2026-06-11T10:00:00.000Z', + }), + dbDeployment({ + id: '102', + commitSha: 'sha-3', + environment: 'production', + createdAt: '2026-06-12T00:00:00.000Z', + }), + ]); + mockDoraDataService.readIncidents.mockResolvedValueOnce([ + dbIncident({ + id: 'INC-1', + // In interval [sha-1, sha-2) + createdAt: '2026-06-11T00:00:00.000Z', + updatedAt: '2026-06-11T00:00:00.000Z', + resolutionAt: null, + }), + dbIncident({ + id: 'INC-2', + // After last successful deployment sha-3; not counted in this run + createdAt: '2026-06-13T00:00:00.000Z', + updatedAt: '2026-06-13T00:00:00.000Z', + resolutionAt: null, + }), + ]); + + const results = await provider.calculateMetrics(mockEntity); + + expect(results.get('dora.changeFailureRate')).toBe(50); // 1 of 2 intervals + }); + it('should throw when fewer than 2 successful production deployments are found', async () => { mockDoraDataService.readDeployments.mockResolvedValueOnce([ dbDeployment({ diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.test.ts index 5d9043a9519..01122fbdba6 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.test.ts @@ -18,6 +18,7 @@ import { stringifyEntityRef } from '@backstage/catalog-model'; import { TestDatabases, mockServices } from '@backstage/backend-test-utils'; import { buildMockCollectorsService, + buildMockDeploymentPullRequestsCollector, buildMockDeploymentsCollector, buildMockIncidentsCollector, mockEntity, @@ -26,6 +27,7 @@ import { createTestDatabase } from '../database/__fixtures__'; import { DefaultDoraDataService } from './DoraDataService'; import { DefaultDoraSyncService } from './DoraSyncService'; import { + DORA_DEFAULT_DEPLOYMENT_PULL_REQUESTS_COLLECTOR_ID, DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, } from '../constants'; @@ -475,4 +477,261 @@ describe('DefaultDoraSyncService', () => { ); }, ); + + it.each(databases.eachSupportedId())( + 'syncs pull requests for a deployment when none are stored yet - %p', + async databaseId => { + const { deployments, incidents, pullRequests, lastSync } = + await createTestDatabase(await databases.init(databaseId)); + const catalogEntityRef = stringifyEntityRef(mockEntity); + + await deployments.upsert([ + { + catalogEntityRef, + collectorId: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + originalDeploymentId: '100', + commitSha: 'sha-head', + environment: 'production', + createdAt: new Date('2026-06-10T00:00:00.000Z'), + }, + ]); + const [deployment] = await deployments.readByEntityCollectorAndWindow( + catalogEntityRef, + DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + new Date('2026-06-01T00:00:00.000Z'), + new Date('2026-06-30T00:00:00.000Z'), + ); + + const pullRequestsCollector = buildMockDeploymentPullRequestsCollector({ + pullRequests: [ + { + id: 'pr-1', + firstCommitAt: '2026-06-09T10:00:00.000Z', + }, + ], + collectorId: DORA_DEFAULT_DEPLOYMENT_PULL_REQUESTS_COLLECTOR_ID, + }); + const { collectorsService, collect } = buildMockCollectorsService({ + collectors: [pullRequestsCollector], + }); + const syncService = new DefaultDoraSyncService( + collectorsService, + deployments, + incidents, + pullRequests, + lastSync, + logger, + ); + const dataService = new DefaultDoraDataService( + deployments, + incidents, + pullRequests, + ); + + await syncService.syncPullRequestsForDeployment(mockEntity, { + deploymentId: deployment.id, + baseCommitSha: 'sha-base', + headCommitSha: 'sha-head', + collector: { + id: DORA_DEFAULT_DEPLOYMENT_PULL_REQUESTS_COLLECTOR_ID, + input: {}, + }, + }); + + expect(collect).toHaveBeenCalledWith( + expect.objectContaining({ + collectorId: DORA_DEFAULT_DEPLOYMENT_PULL_REQUESTS_COLLECTOR_ID, + input: expect.objectContaining({ + baseCommitSha: 'sha-base', + headCommitSha: 'sha-head', + }), + }), + ); + await expect( + dataService.readPullRequestsForDeployment(catalogEntityRef, { + deploymentId: deployment.id, + collector: { + id: DORA_DEFAULT_DEPLOYMENT_PULL_REQUESTS_COLLECTOR_ID, + }, + }), + ).resolves.toEqual([ + expect.objectContaining({ + originalPrId: 'pr-1', + deploymentId: deployment.id, + }), + ]); + }, + ); + + it.each(databases.eachSupportedId())( + 'skips pull request collector when PRs already exist for the deployment - %p', + async databaseId => { + const { deployments, incidents, pullRequests, lastSync } = + await createTestDatabase(await databases.init(databaseId)); + const catalogEntityRef = stringifyEntityRef(mockEntity); + + await deployments.upsert([ + { + catalogEntityRef, + collectorId: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + originalDeploymentId: '100', + commitSha: 'sha-head', + environment: 'production', + createdAt: new Date('2026-06-10T00:00:00.000Z'), + }, + ]); + const [deployment] = await deployments.readByEntityCollectorAndWindow( + catalogEntityRef, + DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + new Date('2026-06-01T00:00:00.000Z'), + new Date('2026-06-30T00:00:00.000Z'), + ); + await pullRequests.upsert([ + { + catalogEntityRef, + collectorId: DORA_DEFAULT_DEPLOYMENT_PULL_REQUESTS_COLLECTOR_ID, + originalPrId: 'pr-existing', + firstCommitAt: new Date('2026-06-09T10:00:00.000Z'), + deploymentId: deployment.id, + }, + ]); + + const pullRequestsCollector = buildMockDeploymentPullRequestsCollector({ + pullRequests: [ + { + id: 'pr-new', + firstCommitAt: '2026-06-09T12:00:00.000Z', + }, + ], + collectorId: DORA_DEFAULT_DEPLOYMENT_PULL_REQUESTS_COLLECTOR_ID, + }); + const { collectorsService, collect } = buildMockCollectorsService({ + collectors: [pullRequestsCollector], + }); + const syncService = new DefaultDoraSyncService( + collectorsService, + deployments, + incidents, + pullRequests, + lastSync, + logger, + ); + const dataService = new DefaultDoraDataService( + deployments, + incidents, + pullRequests, + ); + + await syncService.syncPullRequestsForDeployment(mockEntity, { + deploymentId: deployment.id, + baseCommitSha: 'sha-base', + headCommitSha: 'sha-head', + collector: { + id: DORA_DEFAULT_DEPLOYMENT_PULL_REQUESTS_COLLECTOR_ID, + input: {}, + }, + }); + + expect(collect).not.toHaveBeenCalled(); + await expect( + dataService.readPullRequestsForDeployment(catalogEntityRef, { + deploymentId: deployment.id, + collector: { + id: DORA_DEFAULT_DEPLOYMENT_PULL_REQUESTS_COLLECTOR_ID, + }, + }), + ).resolves.toEqual([ + expect.objectContaining({ originalPrId: 'pr-existing' }), + ]); + }, + ); + + it.each(databases.eachSupportedId())( + 'coalesces concurrent pull request syncs for the same deployment - %p', + async databaseId => { + const { deployments, incidents, pullRequests, lastSync } = + await createTestDatabase(await databases.init(databaseId)); + const catalogEntityRef = stringifyEntityRef(mockEntity); + + await deployments.upsert([ + { + catalogEntityRef, + collectorId: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + originalDeploymentId: '100', + commitSha: 'sha-head', + environment: 'production', + createdAt: new Date('2026-06-10T00:00:00.000Z'), + }, + ]); + const [deployment] = await deployments.readByEntityCollectorAndWindow( + catalogEntityRef, + DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + new Date('2026-06-01T00:00:00.000Z'), + new Date('2026-06-30T00:00:00.000Z'), + ); + + let resolveCollect!: () => void; + const collectGate = new Promise(resolve => { + resolveCollect = resolve; + }); + + const pullRequestsCollector = buildMockDeploymentPullRequestsCollector({ + pullRequests: [ + { + id: 'pr-1', + firstCommitAt: '2026-06-09T10:00:00.000Z', + }, + ], + collectorId: DORA_DEFAULT_DEPLOYMENT_PULL_REQUESTS_COLLECTOR_ID, + }); + jest + .mocked(pullRequestsCollector.collect) + .mockImplementation(async () => { + await collectGate; + return { + pullRequests: [ + { + id: 'pr-1', + firstCommitAt: '2026-06-09T10:00:00.000Z', + }, + ], + }; + }); + + const { collectorsService, collect } = buildMockCollectorsService({ + collectors: [pullRequestsCollector], + }); + const syncService = new DefaultDoraSyncService( + collectorsService, + deployments, + incidents, + pullRequests, + lastSync, + logger, + ); + + const options = { + deploymentId: deployment.id, + baseCommitSha: 'sha-base', + headCommitSha: 'sha-head', + collector: { + id: DORA_DEFAULT_DEPLOYMENT_PULL_REQUESTS_COLLECTOR_ID, + input: {}, + }, + }; + + const first = syncService.syncPullRequestsForDeployment( + mockEntity, + options, + ); + const second = syncService.syncPullRequestsForDeployment( + mockEntity, + options, + ); + resolveCollect(); + await Promise.all([first, second]); + + expect(collect).toHaveBeenCalledTimes(1); + }, + ); }); From a3ba0cc0b79e16b8eec282c2b2af7b527a04fb22 Mon Sep 17 00:00:00 2001 From: Dominika Zemanovicova Date: Mon, 17 Aug 2026 17:05:15 +0200 Subject: [PATCH 18/18] Small code updates Signed-off-by: Dominika Zemanovicova --- .../src/database/DatabaseDoraDeployments.ts | 2 +- .../src/database/DatabaseDoraIncidents.ts | 2 +- .../src/database/DatabaseDoraLastSync.ts | 2 +- .../src/database/DatabaseDoraPullRequests.ts | 2 +- .../scorecard-backend-module-dora/src/scheduler/utils.ts | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.ts index 051f9152201..9698ec9315e 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Knex } from 'knex'; +import { type Knex } from 'knex'; import { randomUUID } from 'node:crypto'; import { fromDoraDeploymentRow, diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.ts index 740f729c5a5..1cdaf0c74cb 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Knex } from 'knex'; +import { type Knex } from 'knex'; import { randomUUID } from 'node:crypto'; import { fromDoraIncidentRow, diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraLastSync.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraLastSync.ts index c84874dcea4..4b54d75ba2b 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraLastSync.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraLastSync.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Knex } from 'knex'; +import { type Knex } from 'knex'; import { asDate } from './mappers'; export interface DoraLastSyncStore { diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.ts index dcf6b643e5f..ea97f5b30a8 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Knex } from 'knex'; +import { type Knex } from 'knex'; import { randomUUID } from 'node:crypto'; import { fromDoraPullRequestRow, diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/utils.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/utils.ts index 2d2c9e6a54f..40a2341cbc7 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/utils.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/utils.ts @@ -14,6 +14,6 @@ * limitations under the License. */ -export const daysToMilliseconds = (days: number) => { +export function daysToMilliseconds(days: number) { return days * 24 * 60 * 60 * 1000; -}; +}