diff --git a/workspaces/scorecard/.changeset/evil-turtles-return.md b/workspaces/scorecard/.changeset/evil-turtles-return.md new file mode 100644 index 00000000000..0ce6bab08d2 --- /dev/null +++ b/workspaces/scorecard/.changeset/evil-turtles-return.md @@ -0,0 +1,8 @@ +--- +'@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-dora': minor +'@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-jira': minor +--- + +Persist DORA collector data in the database and sync incrementally from the last watermark, so metrics reuse stored deployments, incidents, and pull requests instead of refetching the full window every time. + +**BREAKING**: The Jira `jira:incidents` collector contract now requires `updatedSince` (ISO datetime) in the input and `updatedAt` (ISO datetime) on each incident in the output. Custom incident collector implementations must provide these fields. 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..6ba2c8eb4e9 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/README.md +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/README.md @@ -161,3 +161,21 @@ DORA providers follow Scorecard scheduling settings under their metric keys: - `scorecard.metricProviders.dora.changeFailureRate.schedule` See [providers.md](../scorecard-backend/docs/providers.md#metric-collection-scheduling) for schedule schema and defaults. + +## Data retention and staleness + +Configure DORA module data retention and collector staleness behavior under +`scorecard.plugins.dora`: + +```yaml +scorecard: + plugins: + dora: + dataRetentionDays: 365 + staleAfterMs: 60000 +``` + +- `dataRetentionDays`: how long source rows (deployments, incidents, and pull requests linked to expired deployments) are retained before cleanup. Must be at least `30` (the DORA metric computation window). Default: `365`. +- `staleAfterMs`: freshness threshold in milliseconds for deployments and incidents; if the last sync is within this window, those collectors are not refreshed. Default: `60000`. Pull request sync is not gated by `staleAfterMs`; PRs are fetched once per deployment when none are stored yet. + +The module schedules a daily background task, `scorecard-dora:cleanup-expired-data`, that deletes deployments, incidents, and pull requests linked to expired deployments older than `dataRetentionDays`. 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..0cfdf0ed293 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/config.d.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/config.d.ts @@ -21,8 +21,31 @@ import { } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; export interface Config { - /** Configuration for scorecard dora plugin */ + /** Configuration for scorecard dora plugin. */ scorecard?: { + plugins?: { + /** + * Configuration for scorecard dora plugin. + */ + dora?: { + /** + * Number of days to retain scorecard DORA source data (deployments, incidents, + * pull requests) in the database. Older data is cleaned up by the + * `scorecard-dora:cleanup-expired-data` task. + * Must be greater than or equal to the DORA metric computation window (30 days). + * @default 365 + */ + dataRetentionDays?: number; + /** + * Freshness threshold in milliseconds for DORA deployment and incident collector refresh. + * If last successful deployments or incidents sync for a collector is within this value, + * data refresh is skipped and existing database data is reused. + * Set to `0` to always refresh. + * @default 60000 + */ + staleAfterMs?: number; + }; + }; metricProviders?: { dora?: { deploymentFrequency?: { 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..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 @@ -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. @@ -128,10 +130,14 @@ Required input: - `from: string` (ISO datetime) - `to: string` (ISO datetime) +- `updatedSince: string` (ISO datetime) Required output: -- `incidents: Array<{ id: string; createdAt: string; resolutionAt: string | null }>` +- `incidents: Array<{ id: string; createdAt: string; updatedAt: string; resolutionAt: string | null }>` + +`createdAt` and `updatedAt` must be valid ISO datetimes. +`resolutionAt` must be `null` for unresolved incidents or a valid ISO datetime for resolved incidents. Collector-specific extra input fields are allowed, but they do not replace required contract fields. 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/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. 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/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..f2a9c10e501 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/migrations/20260723000000_init.js @@ -0,0 +1,101 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +exports.up = async function up(knex) { + await knex.schema.createTable('dora_deployments', table => { + table.string('id').primary().notNullable(); + table.string('catalog_entity_ref').notNullable(); + table.string('collector_id').notNullable(); + table.string('original_deployment_id').notNullable(); + table.string('commit_sha').notNullable(); + table.string('environment').nullable(); + // Millisecond precision so same-second deployments stay distinct for CFR/lead time. + table.dateTime('created_at', { precision: 3 }).notNullable(); + + table.unique([ + 'catalog_entity_ref', + 'collector_id', + 'original_deployment_id', + ]); + // Deployment reads: entity + collector + created_at window (ordered by created_at) + table.index( + ['catalog_entity_ref', 'collector_id', 'created_at'], + 'dora_deployments_entity_collector_created_at_idx', + ); + }); + + await knex.schema.createTable('dora_incidents', table => { + table.string('id').primary().notNullable(); + table.string('catalog_entity_ref').notNullable(); + table.string('collector_id').notNullable(); + table.string('original_incident_id').notNullable(); + table.dateTime('created_at', { precision: 3 }).notNullable(); + table.dateTime('updated_at', { precision: 3 }).notNullable(); + table.dateTime('resolution_at', { precision: 3 }).nullable(); + + table.unique([ + 'catalog_entity_ref', + 'collector_id', + 'original_incident_id', + ]); + // Incident reads: entity + collector + created_at window (ordered by created_at) + table.index( + ['catalog_entity_ref', 'collector_id', 'created_at'], + 'dora_incidents_entity_collector_created_at_idx', + ); + }); + + await knex.schema.createTable('dora_pull_requests', table => { + table.string('id').primary().notNullable(); + table.string('catalog_entity_ref').notNullable(); + table.string('collector_id').notNullable(); + table.string('original_pr_id').notNullable(); + table.dateTime('first_commit_at', { precision: 3 }).notNullable(); + table + .string('deployment_id') + .references('id') + .inTable('dora_deployments') + .onDelete('CASCADE') + .notNullable(); + + table.unique([ + 'catalog_entity_ref', + 'collector_id', + 'original_pr_id', + 'deployment_id', + ]); + // Lead-time reads: PRs for one entity/collector/deployment + table.index( + ['catalog_entity_ref', 'collector_id', 'deployment_id'], + 'dora_pull_requests_entity_collector_deployment_idx', + ); + }); + + await knex.schema.createTable('dora_last_sync', table => { + table.string('catalog_entity_ref').notNullable(); + table.string('collector_id').notNullable(); + table.dateTime('last_synced_at', { precision: 3 }).notNullable(); + + table.primary(['catalog_entity_ref', 'collector_id']); + }); +}; + +exports.down = async function down(knex) { + await knex.schema.dropTable('dora_last_sync'); + await knex.schema.dropTable('dora_pull_requests'); + await knex.schema.dropTable('dora_incidents'); + await knex.schema.dropTable('dora_deployments'); +}; 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/constants.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/constants.ts index f0fc15637bd..f813f333664 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,7 @@ export const DORA_DEFAULT_DEPLOYMENT_PULL_REQUESTS_COLLECTOR_ID = export const DORA_DEFAULT_INCIDENTS_COLLECTOR_ID = 'jira:incidents'; export const DORA_TIME_WINDOW_DAYS = 30; export const DORA_DEFAULT_PRODUCTION_ENVIRONMENTS = ['production']; +export const DORA_DEFAULT_DATA_RETENTION_DAYS = 365; +export const DORA_DEFAULT_STALE_AFTER_MS = 60_000; +export const DORA_CLEANUP_EXPIRED_DATA_TASK_ID = + 'scorecard-dora:cleanup-expired-data' as const; 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..5f417c959d4 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.test.ts @@ -0,0 +1,276 @@ +/* + * 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'], + }); + + 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'), + }, + ]); + + 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).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'), + }, + ]); + }, + ); + + 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'), + }, + ]); + // 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'), + }, + ]); + + 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'); + }, + ); + + 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'), + }, + { + catalogEntityRef: entityRef, + collectorId: 'github:deploymentWorkflowRuns', + originalDeploymentId: 'dep-1', + commitSha: 'sha-other', + environment: 'production', + createdAt: new Date('2026-06-20T10:00:00.000Z'), + }, + ]); + + 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'), + }, + { + catalogEntityRef: entityRef, + collectorId, + originalDeploymentId: 'dep-1', + commitSha: 'sha-1', + environment: 'production', + createdAt: new Date('2026-06-01T10:00:00.000Z'), + }, + { + catalogEntityRef: entityRef, + collectorId, + originalDeploymentId: 'dep-2', + commitSha: 'sha-2', + environment: 'production', + createdAt: new Date('2026-06-10T10:00:00.000Z'), + }, + { + catalogEntityRef: entityRef, + collectorId: 'github:deploymentWorkflowRuns', + originalDeploymentId: 'dep-other', + commitSha: 'sha-other', + environment: 'production', + createdAt: new Date('2026-06-15T10:00:00.000Z'), + }, + ]); + + 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', + ]); + }, + ); + }); + + 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'), + }, + { + catalogEntityRef: entityRef, + collectorId, + originalDeploymentId: 'dep-new', + commitSha: 'sha-new', + environment: 'production', + createdAt: new Date('2026-06-10T00:00:00.000Z'), + }, + ]); + + 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 new file mode 100644 index 00000000000..9698ec9315e --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraDeployments.ts @@ -0,0 +1,84 @@ +/* + * 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 Knex } from 'knex'; +import { randomUUID } from 'node:crypto'; +import { + fromDoraDeploymentRow, + toDoraDeploymentRow, + type DbDoraDeploymentRow, +} from './mappers'; +import { DbDoraDeployment, DbDoraDeploymentCreate } from './types'; + +export interface DoraDeploymentsStore { + upsert(deployments: DbDoraDeploymentCreate[]): Promise; + readByEntityCollectorAndWindow( + catalogEntityRef: string, + collectorId: string, + from: Date, + to: Date, + ): Promise; + deleteOlderThan(olderThan: 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']); + } + + 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); + } + + 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 new file mode 100644 index 00000000000..310a23d61f6 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.test.ts @@ -0,0 +1,226 @@ +/* + * 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'], + }); + + 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).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, + }, + ]); + }, + ); + + 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', + ]); + }, + ); + }); + + 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 new file mode 100644 index 00000000000..1cdaf0c74cb --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraIncidents.ts @@ -0,0 +1,84 @@ +/* + * 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 Knex } from 'knex'; +import { randomUUID } from 'node:crypto'; +import { + fromDoraIncidentRow, + toDoraIncidentRow, + type DbDoraIncidentRow, +} from './mappers'; +import { DbDoraIncident, DbDoraIncidentCreate } from './types'; + +export interface DoraIncidentsStore { + upsert(incidents: DbDoraIncidentCreate[]): Promise; + readByEntityCollectorAndWindow( + catalogEntityRef: string, + collectorId: string, + from: Date, + to: Date, + ): Promise; + deleteOlderThan(olderThan: 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 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); + } + + 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/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..4b54d75ba2b --- /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 { type 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 new file mode 100644 index 00000000000..b415bd50938 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.test.ts @@ -0,0 +1,261 @@ +/* + * 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__'; +import { DatabaseDoraDeployments } from './DatabaseDoraDeployments'; + +jest.setTimeout(60000); + +async function seedDeployment( + deploymentsDb: DatabaseDoraDeployments, + options: { + entityRef?: string; + originalDeploymentId?: string; + createdAt?: Date; + } = {}, +) { + const entityRef = options.entityRef ?? 'component:default/service-a'; + const deploymentsCollectorId = 'github:deployments'; + const originalDeploymentId = options.originalDeploymentId ?? 'dep-1'; + const createdAt = options.createdAt ?? new Date('2026-06-10T10:00:00.000Z'); + await deploymentsDb.upsert([ + { + catalogEntityRef: entityRef, + collectorId: deploymentsCollectorId, + originalDeploymentId, + commitSha: 'sha-1', + environment: 'production', + createdAt, + }, + ]); + const rows = await deploymentsDb.readByEntityCollectorAndWindow( + entityRef, + deploymentsCollectorId, + new Date('2020-01-01T00:00:00.000Z'), + new Date('2030-01-01T00:00:00.000Z'), + ); + const deployment = rows.find( + row => row.originalDeploymentId === originalDeploymentId, + ); + if (!deployment) { + throw new Error(`Failed to seed deployment ${originalDeploymentId}`); + } + return { entityRef, deployment }; +} + +describe('DatabaseDoraPullRequests', () => { + const databases = TestDatabases.create({ + ids: ['POSTGRES_15', 'SQLITE_3'], + }); + + 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'; + + 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).toEqual([ + { + id: expect.any(String), + catalogEntityRef: entityRef, + collectorId: prCollectorId, + originalPrId: 'pr-1', + firstCommitAt: new Date('2026-06-09T10:00:00.000Z'), + deploymentId: 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']); + }, + ); + }); + + describe('deleteForDeploymentsOlderThan', () => { + it.each(databases.eachSupportedId())( + 'deletes pull requests for deployments older than the cutoff - %p', + async databaseId => { + const { deployments, pullRequests } = await createTestDatabase( + await databases.init(databaseId), + ); + const prCollectorId = 'github:deploymentRangePullRequests'; + const { entityRef, deployment: oldDeployment } = await seedDeployment( + deployments, + { + originalDeploymentId: 'dep-old', + createdAt: new Date('2025-01-01T00:00:00.000Z'), + }, + ); + const { deployment: newDeployment } = await seedDeployment( + deployments, + { + originalDeploymentId: 'dep-new', + createdAt: new Date('2026-06-10T00:00:00.000Z'), + }, + ); + + await pullRequests.upsert([ + { + catalogEntityRef: entityRef, + collectorId: prCollectorId, + originalPrId: 'pr-old', + firstCommitAt: new Date('2026-06-09T10:00:00.000Z'), + deploymentId: oldDeployment.id, + }, + { + catalogEntityRef: entityRef, + collectorId: prCollectorId, + originalPrId: 'pr-new', + firstCommitAt: new Date('2026-06-09T11:00:00.000Z'), + deploymentId: newDeployment.id, + }, + ]); + + const deleted = await pullRequests.deleteForDeploymentsOlderThan( + new Date('2026-01-01T00:00:00.000Z'), + ); + + expect(deleted).toBe(1); + expect( + ( + await pullRequests.readByEntityCollectorAndDeployment( + entityRef, + prCollectorId, + oldDeployment.id, + ) + ).map(row => row.originalPrId), + ).toEqual([]); + expect( + ( + await pullRequests.readByEntityCollectorAndDeployment( + entityRef, + prCollectorId, + newDeployment.id, + ) + ).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 new file mode 100644 index 00000000000..408f38cbf31 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/DatabaseDoraPullRequests.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 { type 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; + /** + * Deletes pull requests whose parent deployment is older than the cutoff (for sqlite without CASCADE delete support). + */ + deleteForDeploymentsOlderThan(olderThan: Date): Promise; +} + +export class DatabaseDoraPullRequests implements DoraPullRequestsStore { + private readonly tableName = 'dora_pull_requests'; + private readonly deploymentsTableName = 'dora_deployments'; + + 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); + } + + async deleteForDeploymentsOlderThan(olderThan: Date): Promise { + return await this.dbClient(this.tableName) + .whereIn( + 'deployment_id', + this.dbClient(this.deploymentsTableName) + .select('id') + .where('created_at', '<', olderThan), + ) + .del(); + } +} 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..afc0d30dd7c --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/__fixtures__/testDatabase.ts @@ -0,0 +1,42 @@ +/* + * 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 { DatabaseDoraLastSync } from '../DatabaseDoraLastSync'; +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), + lastSync: new DatabaseDoraLastSync(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..bc3058d08e0 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/mappers.test.ts @@ -0,0 +1,209 @@ +/* + * 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, + }; + + 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, + }); + }); + + 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'), + }).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', + }), + ).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'), + }); + }); + }); + + 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('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: 'dep-row-1', + }), + ).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: '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 new file mode 100644 index 00000000000..8d519fd1172 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/mappers.ts @@ -0,0 +1,138 @@ +/* + * 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; +}; + +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; +}; + +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, + }; +} + +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), + }; +} + +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, + }; +} + +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/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/database/types.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/types.ts new file mode 100644 index 00000000000..f8ea9e149f9 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/database/types.ts @@ -0,0 +1,70 @@ +/* + * 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; +}; + +export type DbDoraDeployment = { + id: string; + catalogEntityRef: string; + collectorId: string; + originalDeploymentId: string; + commitSha: string; + environment: string | null; + createdAt: Date; +}; + +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; +}; + +export type DbDoraPullRequest = { + id: string; + catalogEntityRef: string; + collectorId: string; + originalPrId: string; + firstCommitAt: Date; + deploymentId: string; +}; 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..3b6bfd07f91 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,13 +14,14 @@ * 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, + dbDeployment, + dbIncident, + mockDoraDataService, + mockDoraSyncService, mockEntity, } from './__fixtures__'; import { @@ -29,57 +30,45 @@ import { } 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 provider: DoraChangeFailureRateProvider; beforeEach(() => { jest.clearAllMocks(); - deploymentsCollector = 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: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, - }); - incidentsCollector = buildMockIncidentsCollector({ - incidents: [ - { - id: 'INC-1', - createdAt: '2026-06-10T12:00:00.000Z', - resolutionAt: '2026-06-10T13:00:00.000Z', - }, - ], - collectorId: DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, - }); - ({ collectorsService, collect } = buildMockCollectorsService({ - collectors: [deploymentsCollector, incidentsCollector], - })); + mockDoraDataService.readDeployments.mockResolvedValue([ + 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-11T00:00:00.000Z', + }), + ]); + 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({}), { - collectorsService, + doraSyncService: mockDoraSyncService, + doraDataService: mockDoraDataService, logger: mockLogger, }); }); + afterEach(() => { + jest.useRealTimers(); + }); + describe('fromConfig', () => { it('should create provider with default thresholds on metric', () => { const metrics = provider.getMetrics(); @@ -93,17 +82,23 @@ describe('DoraChangeFailureRateProvider', () => { }); describe('calculateMetrics', () => { - it('should use default collectors when no config', async () => { + it('should use default collectors', async () => { await provider.calculateMetrics(mockEntity); - expect(collect).toHaveBeenCalledWith( + expect(mockDoraSyncService.syncDeployments).toHaveBeenCalledWith( + mockEntity, expect.objectContaining({ - collectorId: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + collector: expect.objectContaining({ + id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + }), }), ); - expect(collect).toHaveBeenCalledWith( + expect(mockDoraSyncService.syncIncidents).toHaveBeenCalledWith( + mockEntity, expect.objectContaining({ - collectorId: DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, + collector: expect.objectContaining({ + id: DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, + }), }), ); }); @@ -111,41 +106,6 @@ 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 customProvider = DoraChangeFailureRateProvider.fromConfig( new ConfigReader({ scorecard: { @@ -175,264 +135,251 @@ describe('DoraChangeFailureRateProvider', () => { }, }), { - collectorsService: customCollectorsService, + doraSyncService: mockDoraSyncService, + doraDataService: mockDoraDataService, logger: mockLogger, }, ); await customProvider.calculateMetrics(mockEntity); - expect(customCollect).toHaveBeenCalledWith( + expect(mockDoraSyncService.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(mockDoraSyncService.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 sync and read deployments and incidents with correct params', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-06-30T12:00:00.000Z')); + const windowTo = new Date('2026-06-30T12:00:00.000Z'); + const windowFrom = new Date('2026-05-31T12:00:00.000Z'); + + await provider.calculateMetrics(mockEntity); + + expect(mockDoraSyncService.syncDeployments).toHaveBeenCalledWith( + mockEntity, + { + windowFrom, + windowTo, + collector: expect.objectContaining({ + id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + }), + }, + ); + expect(mockDoraSyncService.syncIncidents).toHaveBeenCalledWith( + mockEntity, + { + windowFrom, + windowTo, + collector: expect.objectContaining({ + id: DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, + }), + }, + ); + expect(mockDoraDataService.readDeployments).toHaveBeenCalledWith( + 'component:default/test-component', + { + windowFrom, + windowTo, + collector: expect.objectContaining({ + id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + }), + }, + ); + expect(mockDoraDataService.readIncidents).toHaveBeenCalledWith( + 'component:default/test-component', + { + windowFrom, + windowTo, + collector: expect.objectContaining({ + id: DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, + }), + }, + ); + }); + 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, - }, - ], - }); + 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-11T00: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', + 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); expect(results.get('dora.changeFailureRate')).toBe(50); // 1 failed pair out of 2 pairs }); - it('should throw when fewer than 2 successful production deployments are found', async () => { - jest.mocked(deploymentsCollector.collect).mockResolvedValueOnce({ - deployments: [], - }); + it('should return 0 when evaluated intervals have no incidents', async () => { + mockDoraDataService.readIncidents.mockResolvedValueOnce([]); - await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( - /need at least 2 successful production deployments/, - ); - expect(incidentsCollector.collect).not.toHaveBeenCalled(); + const results = await provider.calculateMetrics(mockEntity); + + expect(results.get('dora.changeFailureRate')).toBe(0); }); - 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', - }, - ], - }); + 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, + }), + ]); - await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( - /need at least 2 successful production deployments.*found 1/, - ); + const results = await provider.calculateMetrics(mockEntity); + + expect(results.get('dora.changeFailureRate')).toBe(50); // 1 of 2 intervals }); - 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', - }, - ], - }); + it('should throw when fewer than 2 successful production deployments are found', async () => { + mockDoraDataService.readDeployments.mockResolvedValueOnce([ + dbDeployment({ + id: '100', + commitSha: 'sha-1', + environment: 'production', + createdAt: '2026-06-10T00:00:00.000Z', + }), + ]); await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( - /need at least 2 successful production deployments.*found 1/, + /need at least 2 successful production deployments/, ); }); - it('should use configured productionEnvironments when filtering deployments', async () => { - const customProvider = DoraChangeFailureRateProvider.fromConfig( - new ConfigReader({ - scorecard: { - metricProviders: { - dora: { - changeFailureRate: { - options: { - productionEnvironments: ['prod'], - }, - }, - }, - }, - }, + 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', }), - { - 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: 'development', + createdAt: '2026-06-11T00:00:00.000Z', + }), + ]); - 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); + it('should throw when fewer than two production deployments are found', 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: 'demo-test', + createdAt: '2026-06-11T00:00:00.000Z', + }), + ]); - expect(results.get('dora.changeFailureRate')).toBe(50); // 1 of 2 intervals + await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( + /need at least 2 successful production deployments.*found 1/, + ); }); 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', - }, - ], - }); + 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-10T00:00:00.000Z', + }), + ]); 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..95de5e14bd7 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,39 @@ * 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 { daysToMilliseconds } from '../scheduler/utils'; +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'; +import { isProductionEnvironment } from './utils/deploymentFilterUtils'; 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 +54,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, }); @@ -103,87 +100,75 @@ export class DoraChangeFailureRateProvider implements MetricProvider<'number'> { async calculateMetrics(entity: Entity): Promise> { const results = new Map(); const to = new Date(); - 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 from = new Date( + to.getTime() - daysToMilliseconds(DORA_TIME_WINDOW_DAYS), + ); - 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 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}`, ); } - 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 ( let deploymentIndex = 0; - deploymentIndex < successfulProductionDeployments.length - 1; + deploymentIndex < productionDeployments.length - 1; deploymentIndex++ ) { - const deployment = successfulProductionDeployments[deploymentIndex]; - const nextDeployment = - successfulProductionDeployments[deploymentIndex + 1]; - const deploymentCreatedAt = new Date(deployment.createdAt).getTime(); - const nextDeploymentCreatedAt = new Date( - nextDeployment.createdAt, - ).getTime(); + const deployment = productionDeployments[deploymentIndex]; + const nextDeployment = productionDeployments[deploymentIndex + 1]; + 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/DoraConfig.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraConfig.test.ts index 5f6c710e7a7..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 @@ -16,16 +16,21 @@ 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, DORA_DEFAULT_PRODUCTION_ENVIRONMENTS, + DORA_DEFAULT_STALE_AFTER_MS, + DORA_TIME_WINDOW_DAYS, } from '../constants'; import { parseDoraChangeFailureRateConfig, + parseDoraDataRetentionDays, parseDoraDeploymentFrequencyConfig, parseDoraMeanTimeToRestoreConfig, parseDoraMedianLeadTimeForChangesConfig, + parseDoraStaleAfterMs, } from './DoraConfig'; describe('DoraConfig', () => { @@ -247,4 +252,104 @@ 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({ + scorecard: { + plugins: { + dora: { + dataRetentionDays: 90, + }, + }, + }, + }), + ), + ).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', () => { + 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 b6113c891bd..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 @@ -21,10 +21,13 @@ 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, DORA_DEFAULT_PRODUCTION_ENVIRONMENTS, + DORA_DEFAULT_STALE_AFTER_MS, + DORA_TIME_WINDOW_DAYS, } from '../constants'; import type { JsonValue } from '@backstage/types'; @@ -269,3 +272,36 @@ 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 { + const dataRetentionDays = + config.getOptionalNumber('scorecard.plugins.dora.dataRetentionDays') ?? + 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; +} + +/** + * 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/metricProviders/DoraDeploymentFrequencyProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraDeploymentFrequencyProvider.test.ts index 6e22df936d9..a914f87eef6 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,37 +17,33 @@ import { ConfigReader } from '@backstage/config'; import { DoraDeploymentFrequencyProvider } from './DoraDeploymentFrequencyProvider'; import { - buildMockCollectorsService, - buildMockDeploymentsCollector, + dbDeployment, + mockDoraDataService, + mockDoraSyncService, mockEntity, } from './__fixtures__'; 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 provider: DoraDeploymentFrequencyProvider; beforeEach(() => { - deploymentsCollector = buildMockDeploymentsCollector({ - deployments: [], - collectorId: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, - }); - ({ collectorsService, collect } = buildMockCollectorsService({ - collectors: [deploymentsCollector], - })); + jest.clearAllMocks(); + mockDoraDataService.readDeployments.mockResolvedValue([]); provider = DoraDeploymentFrequencyProvider.fromConfig( new ConfigReader({}), { - collectorsService, + doraSyncService: mockDoraSyncService, + doraDataService: mockDoraDataService, }, ); }); + afterEach(() => { + jest.useRealTimers(); + }); + describe('fromConfig', () => { it('should create provider with default thresholds on metric', () => { const metrics = provider.getMetrics(); @@ -61,14 +57,23 @@ describe('DoraDeploymentFrequencyProvider', () => { }); describe('calculateMetrics', () => { - it('should use default collectors when no config', async () => { + it('should use default collectors', async () => { await provider.calculateMetrics(mockEntity); - expect(collect).toHaveBeenCalledWith( + + expect(mockDoraSyncService.syncDeployments).toHaveBeenCalledWith( + mockEntity, + expect.objectContaining({ + collector: expect.objectContaining({ + id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + input: {}, + }), + }), + ); + expect(mockDoraDataService.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 +81,6 @@ 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 customProvider = DoraDeploymentFrequencyProvider.fromConfig( new ConfigReader({ @@ -109,73 +104,84 @@ describe('DoraDeploymentFrequencyProvider', () => { }, }), { - collectorsService: customCollectorsService, + doraSyncService: mockDoraSyncService, + doraDataService: mockDoraDataService, }, ); await customProvider.calculateMetrics(mockEntity); - expect(customCollect).toHaveBeenCalledWith( + expect(mockDoraSyncService.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', - }, - ], - }); + it('should sync and read deployments with correct params', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-06-30T12:00:00.000Z')); + const windowTo = new Date('2026-06-30T12:00:00.000Z'); + const windowFrom = new Date('2026-05-31T12:00:00.000Z'); + + await provider.calculateMetrics(mockEntity); + + expect(mockDoraSyncService.syncDeployments).toHaveBeenCalledWith( + mockEntity, + { + windowFrom, + windowTo, + collector: expect.objectContaining({ + id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + input: {}, + }), + }, + ); + expect(mockDoraDataService.readDeployments).toHaveBeenCalledWith( + 'component:default/test-component', + { + windowFrom, + windowTo, + collector: expect.objectContaining({ + id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + }), + }, + ); + }); + + 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', + }), + dbDeployment({ + id: '101', + commitSha: 'sha-2', + createdAt: '2026-06-04T10:00:00.000Z', + }), + dbDeployment({ + id: '102', + commitSha: 'sha-3', + environment: 'development', + createdAt: '2026-06-04T11:00:00.000Z', + }), + ]); 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 () => { - (deploymentsCollector.collect as jest.Mock).mockResolvedValueOnce({ - deployments: [], - }); + mockDoraDataService.readDeployments.mockResolvedValueOnce([]); const results = await provider.calculateMetrics(mockEntity); @@ -183,6 +189,27 @@ describe('DoraDeploymentFrequencyProvider', () => { }); it('should treat configured productionEnvironments as production', async () => { + mockDoraDataService.readDeployments.mockResolvedValueOnce([ + dbDeployment({ + id: '100', + commitSha: 'sha-1', + environment: 'prod', + createdAt: '2026-06-01T10:00:00.000Z', + }), + dbDeployment({ + id: '101', + commitSha: 'sha-2', + environment: 'live', + createdAt: '2026-06-02T10:00:00.000Z', + }), + dbDeployment({ + id: '102', + commitSha: 'sha-3', + environment: 'production', + createdAt: '2026-06-03T10:00:00.000Z', + }), + ]); + const customProvider = DoraDeploymentFrequencyProvider.fromConfig( new ConfigReader({ scorecard: { @@ -198,36 +225,11 @@ describe('DoraDeploymentFrequencyProvider', () => { }, }), { - collectorsService, + doraSyncService: mockDoraSyncService, + doraDataService: mockDoraDataService, }, ); - (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..d1dd2062402 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,51 @@ * 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 { daysToMilliseconds } from '../scheduler/utils'; +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'; +import { isProductionEnvironment } 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), }); } @@ -94,38 +95,34 @@ export class DoraDeploymentFrequencyProvider async calculateMetrics(entity: Entity): Promise> { const results = new Map(); const to = new Date(); - const from = new Date(); - from.setDate(to.getDate() - DORA_TIME_WINDOW_DAYS); + const from = new Date( + to.getTime() - daysToMilliseconds(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 => - isSuccessfulProductionDeployment( - deployment, + const deployments = ( + await this.doraDataService.readDeployments(stringifyEntityRef(entity), { + windowFrom: from, + windowTo: to, + collector: this.config.deploymentsCollector, + }) + ).filter(deployment => + isProductionEnvironment( + deployment.environment, 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..96a805a2d70 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,48 +14,43 @@ * 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, + dbIncident, + mockDoraDataService, + mockDoraSyncService, mockEntity, } from './__fixtures__'; 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 provider: DoraMeanTimeToRestoreProvider; beforeEach(() => { jest.clearAllMocks(); - incidentsCollector = buildMockIncidentsCollector({ - incidents: [ - { - id: 'INC-1', - createdAt: '2026-06-10T10:00:00.000Z', - resolutionAt: '2026-06-10T12:00:00.000Z', - }, - ], - collectorId: DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, - }); - ({ collectorsService, collect } = buildMockCollectorsService({ - collectors: [incidentsCollector], - })); + 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({}), { - collectorsService, + doraSyncService: mockDoraSyncService, + doraDataService: mockDoraDataService, logger: mockLogger, }); }); + afterEach(() => { + jest.useRealTimers(); + }); + describe('fromConfig', () => { it('should create provider with default thresholds on metric', () => { const metrics = provider.getMetrics(); @@ -69,15 +64,23 @@ describe('DoraMeanTimeToRestoreProvider', () => { }); describe('calculateMetrics', () => { - it('should use default collector when no config', async () => { + it('should use default collectors', async () => { await provider.calculateMetrics(mockEntity); - expect(collect).toHaveBeenCalledWith( + expect(mockDoraSyncService.syncIncidents).toHaveBeenCalledWith( + mockEntity, + expect.objectContaining({ + collector: expect.objectContaining({ + id: DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, + input: {}, + }), + }), + ); + expect(mockDoraDataService.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 +88,14 @@ 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], - }); + 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: { @@ -123,45 +118,78 @@ describe('DoraMeanTimeToRestoreProvider', () => { }, }), { - collectorsService: customCollectorsService, + doraSyncService: mockDoraSyncService, + doraDataService: mockDoraDataService, logger: mockLogger, }, ); await customProvider.calculateMetrics(mockEntity); - expect(customCollect).toHaveBeenCalledWith( + expect(mockDoraSyncService.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 sync and read incidents with correct params', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-06-30T12:00:00.000Z')); + const windowTo = new Date('2026-06-30T12:00:00.000Z'); + const windowFrom = new Date('2026-05-31T12:00:00.000Z'); + + await provider.calculateMetrics(mockEntity); + + expect(mockDoraSyncService.syncIncidents).toHaveBeenCalledWith( + mockEntity, + { + windowFrom, + windowTo, + collector: expect.objectContaining({ + id: DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, + input: {}, + }), + }, + ); + expect(mockDoraDataService.readIncidents).toHaveBeenCalledWith( + 'component:default/test-component', + { + windowFrom, + windowTo, + collector: expect.objectContaining({ + id: DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, + }), + }, + ); + }); + 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 - }, - ], - }); + mockDoraDataService.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 +197,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, - }, - ], - }); + mockDoraDataService.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 +212,7 @@ describe('DoraMeanTimeToRestoreProvider', () => { }); it('should throw when no incidents are found', async () => { - jest.mocked(incidentsCollector.collect).mockResolvedValueOnce({ - incidents: [], - }); + 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', @@ -195,15 +220,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', - }, - ], - }); + mockDoraDataService.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 +238,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 - }, - ], - }); + mockDoraDataService.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..a0a1e085cd3 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,39 @@ * 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 { daysToMilliseconds } from '../scheduler/utils'; +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 +54,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, }); @@ -99,42 +100,39 @@ export class DoraMeanTimeToRestoreProvider implements MetricProvider<'number'> { async calculateMetrics(entity: Entity): Promise> { const results = new Map(); const to = new Date(); - const from = new Date(); - from.setDate(to.getDate() - DORA_TIME_WINDOW_DAYS); + const from = new Date( + to.getTime() - daysToMilliseconds(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..197ccd2bfb2 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,12 +14,13 @@ * 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, + dbDeployment, + dbPullRequest, + mockDoraDataService, + mockDoraSyncService, mockEntity, } from './__fixtures__'; import { DoraMedianLeadTimeForChangesProvider } from './DoraMedianLeadTimeForChangesProvider'; @@ -27,72 +28,62 @@ 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 provider: DoraMedianLeadTimeForChangesProvider; beforeEach(() => { jest.clearAllMocks(); - deploymentsCollector = buildMockDeploymentsCollector({ - deployments, - collectorId: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, - }); - deploymentPullRequestsCollector = buildMockDeploymentPullRequestsCollector({ + mockDoraSyncService.syncPullRequestsForDeployment.mockResolvedValue( + undefined, + ); + mockDoraDataService.readDeployments.mockResolvedValue(deployments); + mockDoraDataService.readPullRequestsForDeployment.mockResolvedValue( pullRequests, - collectorId: DORA_DEFAULT_DEPLOYMENT_PULL_REQUESTS_COLLECTOR_ID, - }); - ({ collectorsService, collect } = buildMockCollectorsService({ - collectors: [deploymentsCollector, deploymentPullRequestsCollector], - })); + ); provider = DoraMedianLeadTimeForChangesProvider.fromConfig( new ConfigReader({}), { - collectorsService, + doraSyncService: mockDoraSyncService, + doraDataService: mockDoraDataService, logger: mockLogger, }, ); }); + afterEach(() => { + jest.useRealTimers(); + }); + describe('fromConfig', () => { it('should create provider with default thresholds on metric', () => { const metrics = provider.getMetrics(); @@ -106,24 +97,29 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { }); describe('calculateMetrics', () => { - it('should use default collectors when no config', async () => { + it('should use default collectors', async () => { await provider.calculateMetrics(mockEntity); - expect(collect).toHaveBeenCalledWith( + + expect(mockDoraSyncService.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( + mockDoraSyncService.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,25 +128,6 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { const customDeploymentsCollectorId = 'custom:deployments'; const customDeploymentPullRequestsCollectorId = 'custom:deploymentPullRequests'; - const customDeploymentsCollector = buildMockDeploymentsCollector({ - deployments, - collectorId: customDeploymentsCollectorId, - }); - const customDeploymentPullRequestsCollector = - buildMockDeploymentPullRequestsCollector({ - pullRequests, - collectorId: customDeploymentPullRequestsCollectorId, - }); - const { - collectorsService: customCollectorsService, - collect: customCollect, - } = buildMockCollectorsService({ - collectors: [ - customDeploymentsCollector, - customDeploymentPullRequestsCollector, - ], - }); - const customProvider = DoraMedianLeadTimeForChangesProvider.fromConfig( new ConfigReader({ scorecard: { @@ -181,33 +158,83 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { }, }), { - collectorsService: customCollectorsService, + doraSyncService: mockDoraSyncService, + doraDataService: mockDoraDataService, logger: mockLogger, }, ); await customProvider.calculateMetrics(mockEntity); - expect(customCollect).toHaveBeenCalledTimes(2); - expect(customCollect).toHaveBeenCalledWith( + expect(mockDoraSyncService.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( + mockDoraSyncService.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', + }), + ); + }); + + it('should sync and read deployments and pull requests with correct params', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-06-30T12:00:00.000Z')); + const windowTo = new Date('2026-06-30T12:00:00.000Z'); + const windowFrom = new Date('2026-05-31T12:00:00.000Z'); + + await provider.calculateMetrics(mockEntity); + + expect(mockDoraSyncService.syncDeployments).toHaveBeenCalledWith( + mockEntity, + { + windowFrom, + windowTo, + collector: expect.objectContaining({ + id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + input: {}, + }), + }, + ); + expect(mockDoraDataService.readDeployments).toHaveBeenCalledWith( + 'component:default/test-component', + { + windowFrom, + windowTo, + collector: expect.objectContaining({ + id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + }), + }, + ); + expect( + mockDoraSyncService.syncPullRequestsForDeployment, + ).toHaveBeenCalledWith( + mockEntity, + expect.objectContaining({ + collector: expect.objectContaining({ + id: DORA_DEFAULT_DEPLOYMENT_PULL_REQUESTS_COLLECTOR_ID, + }), + deploymentId: '101', + baseCommitSha: 'sha-previous', + headCommitSha: 'sha-current', }), ); }); @@ -219,81 +246,104 @@ 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 - }); + mockDoraDataService.readDeployments.mockResolvedValueOnce([ + dbDeployment({ + id: '400', + commitSha: 'sha-1', + environment: 'production', + createdAt: '2026-06-10T00:00:00.000Z', + }), + dbDeployment({ + id: '401', + commitSha: 'sha-2', + environment: 'production', + createdAt: '2026-06-11T00:00:00.000Z', + }), + dbDeployment({ + id: '402', + commitSha: 'sha-3', + environment: 'production', + createdAt: '2026-06-12T00:00:00.000Z', + }), + ]); + mockDoraDataService.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( + mockDoraSyncService.syncPullRequestsForDeployment, + ).toHaveBeenCalledTimes(2); + expect( + mockDoraSyncService.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( + mockDoraSyncService.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: [], - }); + mockDoraDataService.readDeployments.mockResolvedValueOnce([]); await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( /need at least 2 successful production deployments/, ); - expect(deploymentPullRequestsCollector.collect).not.toHaveBeenCalled(); + expect( + mockDoraSyncService.syncPullRequestsForDeployment, + ).not.toHaveBeenCalled(); }); it('should use configured productionEnvironments when filtering deployments', async () => { + mockDoraDataService.readDeployments.mockResolvedValueOnce([ + dbDeployment({ + id: '400', + commitSha: 'sha-1', + environment: 'production', + createdAt: '2026-06-10T00:00:00.000Z', + }), + dbDeployment({ + id: '401', + commitSha: 'sha-2', + environment: 'prod', + createdAt: '2026-06-11T00:00:00.000Z', + }), + ]); + const customProvider = DoraMedianLeadTimeForChangesProvider.fromConfig( new ConfigReader({ scorecard: { @@ -309,71 +359,65 @@ describe('DoraMedianLeadTimeForChangesProvider', () => { }, }), { - collectorsService, + doraSyncService: mockDoraSyncService, + doraDataService: mockDoraDataService, 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) + it('should throw when no pull requests with measurable lead time are found', async () => { + mockDoraDataService.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 () => { + mockDoraDataService.readDeployments.mockResolvedValue([ + 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-11T00:00:00.000Z', + }), + dbDeployment({ + id: '102', + commitSha: 'sha-3', + environment: 'production', + createdAt: '2026-06-12T00:00:00.000Z', + }), + ]); + mockDoraSyncService.syncPullRequestsForDeployment .mockRejectedValueOnce(new Error('GitHub compare failed')) - .mockResolvedValueOnce({ - pullRequests: [ - { id: '503', firstCommitAt: '2026-06-11T12:00:00.000Z' }, // 12h - ], - }); + .mockResolvedValueOnce(undefined); + mockDoraDataService.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: mockDoraSyncService, + doraDataService: mockDoraDataService, + logger: mockLogger, + }, + ).calculateMetrics(mockEntity); expect(results.get('dora.medianLeadTimeForChanges')).toBe(12); expect(mockLogger.warn).toHaveBeenCalledWith( @@ -387,35 +431,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 - }, - ], - }); + mockDoraDataService.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 +456,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 () => { + mockDoraSyncService.syncPullRequestsForDeployment.mockRejectedValue( + new Error('collector unavailable'), + ); await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( /no pull requests with a measurable lead time/, @@ -442,32 +470,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..ae3ed833910 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,27 @@ * 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 { daysToMilliseconds } from '../scheduler/utils'; +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 { isSuccessfulProductionDeployment } from './utils/deploymentFilterUtils'; +import { calculateMedian } from './utils/calculationUtils'; +import { isProductionEnvironment } from './utils/deploymentFilterUtils'; type DoraMedianLeadTimeForChangesProviderOptions = { - collectorsService: ScorecardCollectorsService; + doraSyncService: DoraSyncService; + doraDataService: DoraDataService; config: DoraMedianLeadTimeForChangesConfig; logger: LoggerService; }; @@ -49,12 +42,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 +57,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, }); @@ -106,30 +103,28 @@ export class DoraMedianLeadTimeForChangesProvider async calculateMetrics(entity: Entity): Promise> { const results = new Map(); const to = new Date(); - 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 from = new Date( + to.getTime() - daysToMilliseconds(DORA_TIME_WINDOW_DAYS), + ); + + 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 => - isSuccessfulProductionDeployment( - deployment, + const deployments = ( + await this.doraDataService.readDeployments(catalogEntityRef, { + windowFrom: from, + windowTo: to, + collector: this.config.deploymentsCollector, + }) + ).filter(deployment => + isProductionEnvironment( + deployment.environment, this.config.productionEnvironments, ), ); @@ -149,23 +144,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 +164,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..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 @@ -16,4 +16,7 @@ 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 new file mode 100644 index 00000000000..bd0646a217f --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockDoraDataService.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 { DoraDataService } from '../../service/DoraDataService'; + +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..91e44457e54 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockDoraStores.ts @@ -0,0 +1,106 @@ +/* + * 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; + 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), + }; +} + +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; + 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, + }; +} + +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(), + deleteForDeploymentsOlderThan: 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/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..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,16 +14,13 @@ * limitations under the License. */ -import { Deployment } from '../schemas/deploymentSchemas'; -import { - isProductionEnvironment, - isSuccessfulProductionDeployment, -} from './deploymentFilterUtils'; +import { isProductionEnvironment } from './deploymentFilterUtils'; 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', () => { @@ -35,27 +32,4 @@ describe('deploymentFilterUtils', () => { ); }); }); - - describe('isSuccessfulProductionDeployment', () => { - it('requires success and a production environment', () => { - expect( - isSuccessfulProductionDeployment( - { result: 'success', environment: 'production' } as Deployment, - ['production'], - ), - ).toBe(true); - expect( - isSuccessfulProductionDeployment( - { result: 'failure', environment: 'production' } as Deployment, - ['production'], - ), - ).toBe(false); - expect( - isSuccessfulProductionDeployment( - { result: 'success', environment: 'development' } as Deployment, - ['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..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 @@ -14,14 +14,14 @@ * 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). + * + * Only successful deployments are persisted; callers filter by environment. */ export function isProductionEnvironment( - environment: string | undefined, + environment: string | null | undefined, productionEnvironments: string[], ): boolean { if (!environment) { @@ -33,17 +33,3 @@ export function isProductionEnvironment( name => name.toLowerCase() === normalizedEnvironment, ); } - -export function isSuccessfulProductionDeployment( - deployment: Deployment, - 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/module.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts index 9b81ad021ad..e9c11d0a5c3 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts @@ -21,10 +21,22 @@ import { scorecardCollectorsServiceRef, 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 { DatabaseDoraLastSync } from './database/DatabaseDoraLastSync'; +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'; +import { CleanupExpiredDataTask } from './scheduler/CleanupExpiredDataTask'; +import { + parseDoraDataRetentionDays, + parseDoraStaleAfterMs, +} from './metricProviders/DoraConfig'; export const scorecardModuleDora = createBackendModule({ pluginId: 'scorecard', @@ -34,27 +46,72 @@ export const scorecardModuleDora = createBackendModule({ deps: { collectorsService: scorecardCollectorsServiceRef, config: coreServices.rootConfig, + database: coreServices.database, logger: coreServices.logger, metrics: scorecardMetricsExtensionPoint, + scheduler: coreServices.scheduler, }, - async init({ collectorsService, config, logger, metrics }) { + async init({ + collectorsService, + config, + database, + logger, + metrics, + scheduler, + }) { + await migrate(database); + + const dbClient = await database.getClient(); + 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, + logger, + parseDoraStaleAfterMs(config), + ); + 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, }), ); + + await new CleanupExpiredDataTask({ + scheduler, + logger, + 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 new file mode 100644 index 00000000000..950623fc6c7 --- /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 { + LoggerService, + SchedulerService, + SchedulerServiceTaskRunner, +} from '@backstage/backend-plugin-api'; +import { mockServices } from '@backstage/backend-test-utils'; +import { DORA_CLEANUP_EXPIRED_DATA_TASK_ID } from '../constants'; +import { + mockDoraDeploymentsStore, + mockDoraIncidentsStore, + mockDoraPullRequestsStore, +} from '../metricProviders/__fixtures__'; +import { CleanupExpiredDataTask } from './CleanupExpiredDataTask'; + +describe('CleanupExpiredDataTask', () => { + let mockScheduler: jest.Mocked; + let mockLogger: jest.Mocked; + 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(); + mockDoraDeploymentsStore.deleteOlderThan.mockResolvedValue(0); + mockDoraIncidentsStore.deleteOlderThan.mockResolvedValue(0); + mockDoraPullRequestsStore.deleteForDeploymentsOlderThan.mockResolvedValue( + 0, + ); + + mockTaskRunner = { + run: jest.fn().mockResolvedValue(undefined), + }; + + mockScheduler.createScheduledTaskRunner.mockReturnValue( + mockTaskRunner as SchedulerServiceTaskRunner, + ); + + task = new CleanupExpiredDataTask({ + scheduler: mockScheduler, + logger: mockLogger, + dataRetentionDays: 30, + deployments: mockDoraDeploymentsStore, + incidents: mockDoraIncidentsStore, + pullRequests: mockDoraPullRequestsStore, + }); + }); + + 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', () => { + beforeEach(async () => { + mockDoraPullRequestsStore.deleteForDeploymentsOlderThan.mockResolvedValue( + 5, + ); + mockDoraDeploymentsStore.deleteOlderThan.mockResolvedValue(3); + mockDoraIncidentsStore.deleteOlderThan.mockResolvedValue(4); + + await (task as any).cleanupExpiredData(mockLogger); + }); + + 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.deleteForDeploymentsOlderThan, + ).toHaveBeenCalledWith(expectedDate); + expect(mockDoraDeploymentsStore.deleteOlderThan).toHaveBeenCalledWith( + expectedDate, + ); + expect(mockDoraIncidentsStore.deleteOlderThan).toHaveBeenCalledWith( + expectedDate, + ); + }); + + it('deletes pull requests before deployments', () => { + const pullRequestOrder = + mockDoraPullRequestsStore.deleteForDeploymentsOlderThan.mock + .invocationCallOrder[0]; + const deploymentOrder = + mockDoraDeploymentsStore.deleteOlderThan.mock.invocationCallOrder[0]; + + expect(pullRequestOrder).toBeLessThan(deploymentOrder); + }); + + it('logs deleted counts', () => { + expect(mockLogger.info).toHaveBeenCalledWith( + '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 new file mode 100644 index 00000000000..4cce5e72952 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/scheduler/CleanupExpiredDataTask.ts @@ -0,0 +1,101 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + LoggerService, + SchedulerService, + SchedulerServiceTaskScheduleDefinition, +} from '@backstage/backend-plugin-api'; +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'; + +type Options = { + scheduler: SchedulerService; + logger: LoggerService; + dataRetentionDays: number; + deployments: DoraDeploymentsStore; + incidents: DoraIncidentsStore; + pullRequests: DoraPullRequestsStore; +}; + +export class CleanupExpiredDataTask { + private readonly logger: LoggerService; + private readonly scheduler: SchedulerService; + private readonly dataRetentionDays: number; + 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.logger = options.logger; + this.scheduler = options.scheduler; + this.dataRetentionDays = options.dataRetentionDays; + 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 olderThan = new Date( + Date.now() - daysToMilliseconds(this.dataRetentionDays), + ); + + const deletedPullRequests = + await this.pullRequests.deleteForDeploymentsOlderThan(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 ${this.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..40a2341cbc7 --- /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 function daysToMilliseconds(days: number) { + return days * 24 * 60 * 60 * 1000; +} 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..01b818bf434 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraDataService.test.ts @@ -0,0 +1,184 @@ +/* + * 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'), + }, + ]); + + 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'), + }, + ]); + }, + ); + }); + + 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'), + }, + ]); + 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..01122fbdba6 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.test.ts @@ -0,0 +1,737 @@ +/* + * 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, mockServices } from '@backstage/backend-test-utils'; +import { + buildMockCollectorsService, + buildMockDeploymentPullRequestsCollector, + buildMockDeploymentsCollector, + buildMockIncidentsCollector, + mockEntity, +} from '../metricProviders/__fixtures__'; +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'; + +jest.setTimeout(60000); + +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', + 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 { collectorsService, collect } = buildMockCollectorsService({ + collectors: [deploymentsCollector], + }); + + const syncService = new DefaultDoraSyncService( + collectorsService, + deployments, + incidents, + pullRequests, + lastSync, + logger, + ); + const dataService = new DefaultDoraDataService( + deployments, + incidents, + pullRequests, + ); + + const windowFrom = new Date('2026-06-01T00: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: firstWindowTo, + collector: { + id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + input: {}, + }, + }); + + const first = await dataService.readDeployments(catalogEntityRef, { + windowFrom, + windowTo: secondWindowTo, + collector: { id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID }, + }); + + expect(first).toHaveLength(1); + expect(collect).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + from: windowFrom.toISOString(), + to: firstWindowTo.toISOString(), + }), + }), + ); + expect( + ( + await lastSync.getLastSyncedAt( + catalogEntityRef, + DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + ) + )?.toISOString(), + ).toBe(firstWindowTo.toISOString()); + + jest.mocked(deploymentsCollector.collect).mockResolvedValueOnce({ + deployments: [ + { + id: '101', + commitSha: 'sha-2', + environment: 'production', + createdAt: '2026-06-20T00:00:00.000Z', + result: 'success', + }, + ], + }); + + await syncService.syncDeployments(mockEntity, { + windowFrom, + windowTo: secondWindowTo, + collector: { + id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + input: {}, + }, + }); + + const second = await dataService.readDeployments(catalogEntityRef, { + windowFrom, + windowTo: secondWindowTo, + collector: { id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID }, + }); + + expect(second).toHaveLength(2); + expect(collect).toHaveBeenLastCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + from: firstWindowTo.toISOString(), + to: secondWindowTo.toISOString(), + }), + }), + ); + }, + ); + + 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 => { + const { deployments, incidents, pullRequests, lastSync } = + 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, + lastSync, + logger, + ); + + 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: {}, + }, + }; + + // 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(); + await Promise.all([first, second]); + + expect(collect).toHaveBeenCalledTimes(1); + }, + ); + + it.each(databases.eachSupportedId())( + 'syncs incidents updated since the last successful sync watermark - %p', + async databaseId => { + const { deployments, incidents, pullRequests, lastSync } = + 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, + lastSync, + logger, + ); + + const windowFrom = new Date('2026-06-01T00: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: firstWindowTo, + collector: { + id: DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, + input: {}, + }, + }); + + expect(collect).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + from: windowFrom.toISOString(), + to: firstWindowTo.toISOString(), + updatedSince: windowFrom.toISOString(), + }), + }), + ); + + await syncService.syncIncidents(mockEntity, { + windowFrom, + windowTo: secondWindowTo, + collector: { + id: DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, + input: {}, + }, + }); + + expect(collect).toHaveBeenLastCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + from: windowFrom.toISOString(), + to: secondWindowTo.toISOString(), + updatedSince: firstWindowTo.toISOString(), + }), + }), + ); + }, + ); + + 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).`, + ); + }, + ); + + 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); + }, + ); +}); 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..158e7bd3336 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/DoraSyncService.ts @@ -0,0 +1,292 @@ +/* + * 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 { 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, +} 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 { coalesceInFlight, isWithinStaleWindow, 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 { + private readonly inflightDeployments = new Map>(); + private readonly inflightIncidents = new Map>(); + private readonly inflightPullRequests = new Map>(); + + constructor( + private readonly collectorsService: ScorecardCollectorsService, + private readonly deploymentsDb: DoraDeploymentsStore, + 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, + ) {} + + /** + * 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. + */ + 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 lastSyncedAt = await this.lastSyncDb.getLastSyncedAt( + 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< + 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 + .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( + catalogEntityRef, + collectorId, + options.windowTo, + ); + } + + /** + * 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. + */ + 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 lastSyncedAt = await this.lastSyncDb.getLastSyncedAt( + 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< + 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, + })), + ); + + await this.lastSyncDb.setLastSyncedAt( + catalogEntityRef, + collectorId, + options.windowTo, + ); + } + + /** + * 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. + */ + syncPullRequestsForDeployment( + entity: Entity, + options: CollectorCallOptions & { + deploymentId: string; + baseCommitSha: string; + headCommitSha: string; + }, + ): 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 = + 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..1f3dde47412 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/syncUtils.test.ts @@ -0,0 +1,127 @@ +/* + * 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 { coalesceInFlight, isWithinStaleWindow, 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); + }); +}); + +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); + }); +}); + +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 new file mode 100644 index 00000000000..2c69c4fca10 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/service/syncUtils.ts @@ -0,0 +1,58 @@ +/* + * 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; +} + +/** + * 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. + */ +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; +} 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; +}; 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..789927f6a92 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 @@ -16,14 +16,15 @@ import type { JiraSearchIssue } from './schemas/jiraSearchIssue'; import type { JiraIssue } from './types'; -import { toIsoDateTime } from './utils'; +import { jiraDateTimeToIso } from './utils'; export function mapJiraIssues(issues: JiraSearchIssue[]): JiraIssue[] { return issues.map(issue => ({ id: issue.id, - createdAt: toIsoDateTime(issue.fields.created), + createdAt: jiraDateTimeToIso(issue.fields.created), + updatedAt: jiraDateTimeToIso(issue.fields.updated), resolutionAt: issue.fields.resolutiondate - ? toIsoDateTime(issue.fields.resolutiondate) + ? jiraDateTimeToIso(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/clients/utils.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/utils.test.ts index 3b419ca6c96..a3be789ad71 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/utils.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/utils.test.ts @@ -16,8 +16,8 @@ import { joinJqlClauses, - toIsoDateTime, - toJiraDateTime, + jiraDateTimeToIso, + toJiraEpochMillis, validateIdentifier, validateJQLValue, } from './utils'; @@ -53,10 +53,10 @@ describe('utils', () => { joinJqlClauses([ 'project = "INC"', 'type = Incident', - 'created >= "2026-06-01 00:00"', + 'created >= 1780272000000', ]), ).toBe( - '(project = "INC") AND (type = Incident) AND (created >= "2026-06-01 00:00")', + '(project = "INC") AND (type = Incident) AND (created >= 1780272000000)', ); }); @@ -81,17 +81,15 @@ describe('utils', () => { }); }); - describe('toJiraDateTime', () => { - it('should convert ISO datetime to Jira datetime format', () => { - expect(toJiraDateTime('2026-06-01T10:05:00.000Z')).toBe( - '2026-06-01 10:05', - ); + describe('toJiraEpochMillis', () => { + it('should convert ISO datetime to Unix epoch milliseconds', () => { + expect(toJiraEpochMillis('2026-06-01T10:05:00.000Z')).toBe(1780308300000); }); }); - describe('toIsoDateTime', () => { - it('should normalize Jira datetime offset without colon', () => { - expect(toIsoDateTime('2026-07-15T18:21:34.862+0530')).toBe( + describe('jiraDateTimeToIso', () => { + it('should reformat Jira datetime with colon-less offset to ISO-8601', () => { + expect(jiraDateTimeToIso('2026-07-15T18:21:34.862+0530')).toBe( '2026-07-15T12:51:34.862Z', ); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/utils.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/utils.ts index d7268f01a5f..775a52361e7 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/utils.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/utils.ts @@ -41,32 +41,34 @@ export function joinJqlClauses( .join(' AND '); } -export function toJiraDateTime(value: string): string { - const parsedDate = parseDateTime(value); - - const year = parsedDate.getUTCFullYear(); - const month = String(parsedDate.getUTCMonth() + 1).padStart(2, '0'); - const day = String(parsedDate.getUTCDate()).padStart(2, '0'); - const hours = String(parsedDate.getUTCHours()).padStart(2, '0'); - const minutes = String(parsedDate.getUTCMinutes()).padStart(2, '0'); - - return `${year}-${month}-${day} ${hours}:${minutes}`; -} - -export function toIsoDateTime(value: string): string { - return parseDateTime(value).toISOString(); +/** + * Converts a validated ISO datetime to Unix epoch milliseconds for JQL. + * + * Unquoted numbers in JQL date comparisons are treated as milliseconds since + * epoch (1970-01-01). Quoted `"yyyy-MM-dd HH:mm"` values use the configured + * (usually server) timezone. Epoch avoids that skew. + * + * @see https://support.atlassian.com/jira-software-cloud/docs/jql-fields/ (`created`, `updated` fields) + * @see https://confluence.atlassian.com/jiracoreserver/advanced-searching-fields-reference-939937719.html (`created`, `updated` fields) + */ +export function toJiraEpochMillis(value: string): number { + return new Date(value).getTime(); } -function parseDateTime(value: string): Date { - const normalizedValue = normalizeTimezone(value); +/** + * Reformats a datetime from a Jira API response to strict ISO-8601. + * Jira may return offsets without a colon (`+0530`); those are normalized. + */ +export function jiraDateTimeToIso(value: string): string { + const normalizedValue = normalizeJiraOffset(value); const parsedDate = new Date(normalizedValue); if (Number.isNaN(parsedDate.getTime())) { - throw new TypeError(`Invalid datetime "${value}"`); + throw new TypeError(`Invalid Jira datetime "${value}"`); } - return parsedDate; + return parsedDate.toISOString(); } -function normalizeTimezone(value: string): string { - // Jira can return offsets like +0530; normalize to +05:30 for strict ISO parsing. +/** Jira can return offsets like `+0530`; ISO expects `+05:30`. */ +function normalizeJiraOffset(value: string): string { return value.replace(/([+-]\d{2})(\d{2})$/, '$1:$2'); } 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..d18aee8e9f5 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 >= 1780272000000) AND (created <= 1782863999999) AND (updated >= 1780272000000)', ); }); @@ -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 >= 1780272000000) AND (created <= 1782863999999) AND (updated >= 1780272000000)', ); }); @@ -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 >= 1780272000000) AND (created <= 1782863999999) AND (updated >= 1780272000000)', ); }); @@ -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 >= 1780272000000) AND (created <= 1782863999999) AND (updated >= 1780272000000)', ); }); 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..cc9c6c55b62 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 >= 1780272000000) AND (created <= 1782863999999) AND (updated >= 1777593600000)', ); }); @@ -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 >= 1780272000000) AND (created <= 1782863999999) AND (updated >= 1777593600000)', ); }); @@ -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 >= 1780272000000) AND (created <= 1782863999999) AND (updated >= 1777593600000)', ); }); @@ -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 >= 1780272000000) AND (created <= 1782863999999) AND (updated >= 1777593600000)', ); 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 >= 1780272000000) AND (created <= 1782863999999) AND (updated >= 1777593600000)', ); }); }); 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..e75254577bc 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 @@ -21,7 +21,7 @@ import { } from '../annotations'; import { joinJqlClauses, - toJiraDateTime, + toJiraEpochMillis, validateJQLValue, } from '../clients/utils'; import { DEFAULT_INCIDENT_ISSUE_TYPE } from '../constants'; @@ -34,18 +34,24 @@ export function buildIncidentJql( from: string; to: string; issueType?: string; + updatedSince: string; }, entity: Entity, ): string { - const from = toJiraDateTime(options.from); - const to = toJiraDateTime(options.to); + // use timezone-safe JQL date comparisons + // otherwise we would need to look up timezone and convert (e.g. /myself, /serverInfo) + const from = toJiraEpochMillis(options.from); + const to = toJiraEpochMillis(options.to); + const updatedSince = toJiraEpochMillis(options.updatedSince); const issueType = resolveIncidentIssueType(entity, options.issueType); + // Epoch millis must be unquoted in JQL (quoted values are parsed as local datetime). return joinJqlClauses([ ...Object.values(filters), `type = "${issueType}"`, - `created >= "${from}"`, - `created <= "${to}"`, + `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/constants/index.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/constants/index.ts index 358641b099c..de90943afaa 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/constants/index.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/constants/index.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +export * from './jira'; export * from './jiraOpenIssues'; export * from './jiraIncidents'; export * from './pagination'; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/constants/jira.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/constants/jira.ts new file mode 100644 index 00000000000..81b89a2fa0e --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/constants/jira.ts @@ -0,0 +1,25 @@ +/* + * 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. + */ + +/** + * Jira integration configuration path + * @public + */ +export const JIRA_CONFIG_PATH = 'jira' as const; + +export const DATA_CENTER_API_VERSION = 2 as const; + +export const CLOUD_API_VERSION = 3 as const; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/constants/jiraOpenIssues.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/constants/jiraOpenIssues.ts index 11acc0ab035..46892af1cf3 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/constants/jiraOpenIssues.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/constants/jiraOpenIssues.ts @@ -17,15 +17,5 @@ export const OPEN_ISSUES_CONFIG_PATH = 'scorecard.metricProviders.jira.openIssues' as const; -/** - * Jira integration configuration path - * @public - */ -export const JIRA_CONFIG_PATH = 'jira' as const; - -export const DATA_CENTER_API_VERSION = 2 as const; - -export const CLOUD_API_VERSION = 3 as const; - export const JIRA_MANDATORY_FILTER = 'type = Bug AND resolution = Unresolved' as const; 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), 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