diff --git a/workspaces/scorecard/.changeset/quick-monkeys-wash.md b/workspaces/scorecard/.changeset/quick-monkeys-wash.md new file mode 100644 index 00000000000..7124ec7a1b5 --- /dev/null +++ b/workspaces/scorecard/.changeset/quick-monkeys-wash.md @@ -0,0 +1,15 @@ +--- +'@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-dora': minor +'@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-github': minor +'@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-jira': minor +'@red-hat-developer-hub/backstage-plugin-scorecard-backend': minor +'@red-hat-developer-hub/backstage-plugin-scorecard-common': minor +'@red-hat-developer-hub/backstage-plugin-scorecard': minor +--- + +Add DORA metrics and a collectors framework for composing datasource data into metrics. + +- New `@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-dora` with Deployment Frequency, Median Lead Time for Changes, Mean Time to Restore, and Change Failure Rate +- New data collectors used by DORA: GitHub deployments, deployment workflow runs, and deployment pull requests; Jira incidents +- Metric time-series API `/metrics/catalog/:kind/:namespace/:name/time-series` +- Adds `defaultVisualization` to Metric metadata for sparkline diff --git a/workspaces/scorecard/AGENTS.md b/workspaces/scorecard/AGENTS.md index ce48995df2d..e35c80f5e75 100644 --- a/workspaces/scorecard/AGENTS.md +++ b/workspaces/scorecard/AGENTS.md @@ -13,9 +13,9 @@ All metric IDs use `lowerCamelCase` with a `.` format: -- Provider prefix is lowercase: `github`, `jira`, `sonarqube`, `dependabot`, `openssf`, `filecheck` +- Provider prefix is lowercase: `github`, `jira`, `sonarqube`, `dependabot`, `openssf`, `filecheck`, `dora` - Metric name is lowerCamelCase: `openPRs`, `qualityGate`, `ciiBestPractices` -- Full ID examples: `github.openPRs`, `sonarqube.qualityGate`, `openssf.ciiBestPractices` +- Full ID examples: `github.openPRs`, `sonarqube.qualityGate`, `openssf.ciiBestPractices`, `dora.deploymentFrequency` Never use snake_case for metric IDs. SonarQube API keys (e.g., `security_rating`, `code_smells`) are external API field names and remain @@ -35,6 +35,15 @@ snake_case in the API layer only -- they are not metric IDs. | ----------------- | ------ | --------------------------- | | `jira.openIssues` | number | `JiraOpenIssuesProvider.ts` | +### DORA (4 metrics) + +| Metric ID | Type | Source | +| ------------------------------- | ------ | -------------------------------------------- | +| `dora.deploymentFrequency` | number | `DoraDeploymentFrequencyProvider.ts` | +| `dora.medianLeadTimeForChanges` | number | `DoraMedianLeadTimeForChangesProvider.ts` | +| `dora.meanTimeToRestore` | number | `DoraMeanTimeToRestoreProvider.ts` | +| `dora.changeFailureRate` | number | `DoraChangeFailureRateProvider.ts` | + ### Dependabot (4 metrics) | Metric ID | Type | Source | diff --git a/workspaces/scorecard/app-config.yaml b/workspaces/scorecard/app-config.yaml index 8e5e5a40f2d..52b8f76e4f4 100644 --- a/workspaces/scorecard/app-config.yaml +++ b/workspaces/scorecard/app-config.yaml @@ -363,3 +363,62 @@ scorecard: frequency: { minutes: 5 } timeout: { minutes: 10 } initialDelay: { seconds: 10 } + dora: + deploymentFrequency: + options: + collectors: + deployments: + id: github:deployments + # Uncomment the following to use workflow runs + # id: github:deploymentWorkflowRuns + # input: + # workflowName: Create Test Deployment on PR Merge + schedule: + frequency: { minutes: 5 } + timeout: { minutes: 10 } + initialDelay: { seconds: 10 } + medianLeadTimeForChanges: + options: + collectors: + deployments: + id: github:deployments + # Uncomment the following to use workflow runs + # id: github:deploymentWorkflowRuns + # input: + # workflowName: Create Test Deployment on PR Merge + deploymentPullRequests: + id: github:deploymentPullRequests + schedule: + frequency: { minutes: 5 } + timeout: { minutes: 10 } + initialDelay: { seconds: 10 } + changeFailureRate: + options: + collectors: + deployments: + id: github:deployments + # Uncomment the following to use workflow runs + # id: github:deploymentWorkflowRuns + # input: + # workflowName: Create Test Deployment on PR Merge + incidents: + id: jira:incidents + # Optional: override default Incident issue type + # input: + # issueType: ServiceIncident + schedule: + frequency: { minutes: 5 } + timeout: { minutes: 10 } + initialDelay: { seconds: 10 } + meanTimeToRestore: + options: + collectors: + incidents: + id: jira:incidents + # Optional: override default Incident issue type + # input: + # issueType: ServiceIncident + schedule: + frequency: { minutes: 5 } + timeout: { minutes: 10 } + initialDelay: { seconds: 10 } diff --git a/workspaces/scorecard/examples/all-scorecards-location.yaml b/workspaces/scorecard/examples/all-scorecards-location.yaml index c9adc35a081..a5dabed9f42 100644 --- a/workspaces/scorecard/examples/all-scorecards-location.yaml +++ b/workspaces/scorecard/examples/all-scorecards-location.yaml @@ -14,3 +14,4 @@ spec: - ./components/no-scorecards.yaml - ./components/openssf-scorecard-only.yaml - ./components/sonarqube-scorecard-only.yaml + - ./components/dora-scorecard.yaml diff --git a/workspaces/scorecard/examples/components/dora-scorecard.yaml b/workspaces/scorecard/examples/components/dora-scorecard.yaml new file mode 100644 index 00000000000..c0384ad3410 --- /dev/null +++ b/workspaces/scorecard/examples/components/dora-scorecard.yaml @@ -0,0 +1,15 @@ +--- +# Component with DORA Scorecard +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: dora-scorecard + annotations: + github.com/project-slug: dzemanov/test-scorecard-github-dora + backstage.io/source-location: url:https://github.com/dzemanov/test-scorecard-github-dora + scorecard.io/dora: 'true' + jira/incident-project-key: RSPT +spec: + type: service + owner: group:development/guests + lifecycle: experimental diff --git a/workspaces/scorecard/packages/backend/package.json b/workspaces/scorecard/packages/backend/package.json index 9f3f260533f..d40d07e7537 100644 --- a/workspaces/scorecard/packages/backend/package.json +++ b/workspaces/scorecard/packages/backend/package.json @@ -49,6 +49,7 @@ "@backstage/plugin-techdocs-backend": "^2.2.1", "@red-hat-developer-hub/backstage-plugin-scorecard-backend": "workspace:^", "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-dependabot": "workspace:^", + "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-dora": "workspace:^", "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-filecheck": "workspace:^", "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-github": "workspace:^", "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-jira": "workspace:^", diff --git a/workspaces/scorecard/packages/backend/src/index.ts b/workspaces/scorecard/packages/backend/src/index.ts index 828b1f15385..2393a4f4c71 100644 --- a/workspaces/scorecard/packages/backend/src/index.ts +++ b/workspaces/scorecard/packages/backend/src/index.ts @@ -92,5 +92,10 @@ backend.add( '@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-sonarqube' ), ); +backend.add( + import( + '@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-dora' + ), +); backend.add(import('@backstage/plugin-mcp-actions-backend')); backend.start(); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/.eslintrc.js b/workspaces/scorecard/plugins/scorecard-backend-module-dora/.eslintrc.js new file mode 100644 index 00000000000..e2a53a6ad28 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/README.md b/workspaces/scorecard/plugins/scorecard-backend-module-dora/README.md new file mode 100644 index 00000000000..56c0f2ae358 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/README.md @@ -0,0 +1,163 @@ +# Scorecard Backend Module for DORA + +This is an extension module to the `backstage-plugin-scorecard-backend` plugin that provides DORA (DevOps Research and Assessment) metrics – key indicators of software delivery performance. + +DORA module uses [**collectors**](../scorecard-backend/docs/collectors.md) – reusable components designed to gather data from various datasources, such as Jira or GitHub. You can create your custom data collector to tailor data collection for DORA metrics calculation for your specific setup. + +## Prerequisites + +Before installing this module, ensure that the Scorecard backend plugin is integrated into your Backstage instance. Follow the [Scorecard backend plugin README](../scorecard-backend/README.md) for setup instructions. + +If you use built-in collectors from GitHub and Jira modules, install the corresponding backend modules so those collectors are registered: + +- `@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-github` +- `@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-jira` + +## Installation + +To install this backend module: + +```bash +# From your root directory +yarn workspace backend add @red-hat-developer-hub/backstage-plugin-scorecard-backend-module-dora +``` + +```ts +// packages/backend/src/index.ts +import { createBackend } from '@backstage/backend-defaults'; + +const backend = createBackend(); + +backend.add( + import('@red-hat-developer-hub/backstage-plugin-scorecard-backend'), +); + +backend.add( + import( + '@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-dora' + ), +); + +backend.start(); +``` + +### Entity annotations + +DORA metric providers run only for entities that include: + +```yaml +metadata: + annotations: + scorecard.io/dora: 'true' +``` + +## Available Metrics + +| Metric ID | Provider ID | Default thresholds | Details | +| ------------------------------- | ------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------- | +| `dora.deploymentFrequency` | `dora.deploymentFrequency` | elite `>=7`, medium `1-7`, low `<1` (deployments/week) | [deployment-frequency.md](./docs/metrics/deployment-frequency.md) | +| `dora.medianLeadTimeForChanges` | `dora.medianLeadTimeForChanges` | elite `<24`, medium `24-168`, low `>168` (hours) | [median-lead-time-for-changes.md](./docs/metrics/median-lead-time-for-changes.md) | +| `dora.meanTimeToRestore` | `dora.meanTimeToRestore` | elite `<1`, medium `1-24`, low `>24` (hours) | [mean-time-to-restore.md](./docs/metrics/mean-time-to-restore.md) | +| `dora.changeFailureRate` | `dora.changeFailureRate` | elite `<5`, medium `5-15`, low `>15` (%) | [change-failure-rate.md](./docs/metrics/change-failure-rate.md) | + +## Threshold customization + +Thresholds map metric values to visual categories. DORA defaults use `elite`, `medium`, and `low` (see [Available Metrics](#available-metrics)). + +You can customize them in two ways (highest priority first): + +1. **Entity annotations** — merge with existing rules (same keys only) +2. **App configuration** — replace provider defaults for that metric + +See [threshold configuration](../scorecard-backend/docs/thresholds.md) for details. + +**App configuration example**: + +```yaml +# app-config.yaml +scorecard: + metricProviders: + dora: + deploymentFrequency: + thresholds: + rules: + - key: elite + expression: '>=5' + - key: medium + expression: '1-5' + - key: low + expression: '<1' +``` + +Paths follow `scorecard.metricProviders.dora..thresholds` (update `metricProviderName` to `deploymentFrequency`, `medianLeadTimeForChanges`, `meanTimeToRestore` or `changeFailureRate`). + +**Entity annotation example** (overrides selected keys; others keep app-config or defaults): + +```yaml +# catalog-info.yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: my-service + annotations: + scorecard.io/dora: 'true' + # Format: scorecard.io/{metricId}.thresholds.rules.{key}: '{expression}' + scorecard.io/dora.deploymentFrequency.thresholds.rules.elite: '>=8' + scorecard.io/dora.deploymentFrequency.thresholds.rules.medium: '1-8' + scorecard.io/dora.changeFailureRate.thresholds.rules.elite: '<10' + scorecard.io/dora.changeFailureRate.thresholds.rules.medium: '10-20' + scorecard.io/dora.changeFailureRate.thresholds.rules.low: '>20' +spec: + type: service + lifecycle: production + owner: team-a +``` + +## Use your own collectors + +You can replace default collector IDs via `app-config.yaml` as long as your collectors implement the schema contracts expected by each metric: + +- `dora.deploymentFrequency` [collector contracts](./docs/metrics/deployment-frequency.md#collectors) +- `dora.medianLeadTimeForChanges` [collector contracts](./docs/metrics/median-lead-time-for-changes.md#collectors) +- `dora.meanTimeToRestore` [collector contracts](./docs/metrics/mean-time-to-restore.md#collectors) +- `dora.changeFailureRate` [collector contracts](./docs/metrics/change-failure-rate.md#collectors) + +Collector inputs are merged with provider-generated required inputs. This lets you pass extra collector-specific fields (for example `workflowName` when using a workflow-runs based collector) as long as required contract fields are still supported. + +```yaml +scorecard: + metricProviders: + dora: + deploymentFrequency: + options: + productionEnvironments: [production, prod] + collectors: + deployments: + id: customDatasource:deployments + input: + # merged with generated from/to window + # your collector-specific options + medianLeadTimeForChanges: + options: + productionEnvironments: [production, prod] + collectors: + deployments: + id: customDatasource:deployments + input: + # merged with generated from/to window + deploymentPullRequests: + id: customDatasource:deploymentPullRequests + input: + # merged with generated baseCommitSha/headCommitSha +``` + +## Scheduling + +DORA providers follow Scorecard scheduling settings under their metric keys: + +- `scorecard.metricProviders.dora.deploymentFrequency.schedule` +- `scorecard.metricProviders.dora.medianLeadTimeForChanges.schedule` +- `scorecard.metricProviders.dora.meanTimeToRestore.schedule` +- `scorecard.metricProviders.dora.changeFailureRate.schedule` + +See [providers.md](../scorecard-backend/docs/providers.md#metric-collection-scheduling) for schedule schema and defaults. diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/config.d.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/config.d.ts new file mode 100644 index 00000000000..930945328f3 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/config.d.ts @@ -0,0 +1,99 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { SchedulerServiceTaskScheduleDefinitionConfig } from '@backstage/backend-plugin-api'; +import { + CollectorConfig, + ThresholdConfig, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +export interface Config { + /** Configuration for scorecard dora plugin */ + scorecard?: { + metricProviders?: { + dora?: { + deploymentFrequency?: { + /** + * Provider-specific options. + */ + options?: { + /** + * Environment names treated as production (case-insensitive). + * Missing/unknown deployment environments still count as production. + * @default ['production'] + */ + productionEnvironments?: string[]; + collectors?: { + deployments?: CollectorConfig; + }; + }; + thresholds?: ThresholdConfig; + schedule?: SchedulerServiceTaskScheduleDefinitionConfig; + }; + medianLeadTimeForChanges?: { + /** + * Provider-specific options. + */ + options?: { + /** + * Environment names treated as production (case-insensitive). + * Missing/unknown deployment environments still count as production. + * @default ['production'] + */ + productionEnvironments?: string[]; + collectors?: { + deployments?: CollectorConfig; + deploymentPullRequests?: CollectorConfig; + }; + }; + thresholds?: ThresholdConfig; + schedule?: SchedulerServiceTaskScheduleDefinitionConfig; + }; + meanTimeToRestore?: { + /** + * Provider-specific options. + */ + options?: { + collectors?: { + incidents?: CollectorConfig; + }; + }; + thresholds?: ThresholdConfig; + schedule?: SchedulerServiceTaskScheduleDefinitionConfig; + }; + changeFailureRate?: { + /** + * Provider-specific options. + */ + options?: { + /** + * Environment names treated as production (case-insensitive). + * Missing/unknown deployment environments still count as production. + * @default ['production'] + */ + productionEnvironments?: string[]; + collectors?: { + deployments?: CollectorConfig; + incidents?: CollectorConfig; + }; + }; + thresholds?: ThresholdConfig; + schedule?: SchedulerServiceTaskScheduleDefinitionConfig; + }; + }; + }; + }; +} 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 new file mode 100644 index 00000000000..b5baef580a0 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/docs/metrics/change-failure-rate.md @@ -0,0 +1,198 @@ +# DORA Change Failure Rate + +- **Metric ID**: `dora.changeFailureRate` +- **Type**: Number +- **Unit**: percentage +- **Computation window**: 30 days + +Change Failure Rate measures how often production deployments lead to failures that require incident response. + +The metric computes the percentage of successful production deployment intervals that contain at least one incident, out of all evaluated successful production deployment intervals. +Only successful production deployments that happen within the metric's 30-day computation window are evaluated. +Deployments are processed as chronological pairs (`deployment` -> `nextDeployment`), and each pair defines an interval: +`[deployment.createdAt, nextDeployment.createdAt)`. + +For each interval, if at least one incident has `createdAt` in that interval, the deployment is treated as failed. +The result is: `(deploymentsWithIncidents / evaluatedDeployments) * 100`. + +The metric is **deployment-interval based**, not incident-window based: only incidents that fall between two successful production deployments are scored. An incident after the latest successful production deployment in the 30-day window is not counted in that run, even if it was created within the DORA 30-day window. It is attributed in a later DORA calculation to the interval closed by the next successful production deployment (the first deployment that follows). + +If fewer than two successful production deployments exist in the window, or there are no evaluable intervals (adjacent deployments share the same `createdAt`), calculation fails with an error. + +## Scope and limitation + +This metric assumes deployments form a single chronological stream for the entity. +If deployments from multiple branches or release trains are mixed, interval pairing may not reflect actual release flow and can produce noisy change-failure-rate results. + +## Options + +Provider-specific settings are under `options`: + +```yaml +scorecard: + metricProviders: + dora: + changeFailureRate: + options: + productionEnvironments: + - production + - prod + collectors: + deployments: + id: github:deployments + incidents: + id: jira:incidents +``` + +- `productionEnvironments` + - Default: `['production']` + - Matching is case-insensitive; a deployment counts if its environment matches **any** configured name + - Missing/unknown `environment` still counts as production +- `collectors` — see [Collectors](#collectors) + +## Default thresholds + +Thresholds are applied to the computed percentage value: + +- `elite`: `<5` +- `medium`: `5-15` +- `low`: `>15` + +Configure thresholds via: + +- `scorecard.metricProviders.dora.changeFailureRate.thresholds` + +## Collectors + +DORA module uses [**collectors**](../../../scorecard-backend/docs/collectors.md) - reusable components designed to gather data from various datasources, such as Jira or GitHub. You can create your custom data collector to tailor data collection for DORA metrics calculation for your specific setup. + +This metric requires two collectors: [Deployments collector](#deployments-collector) and [Incidents collector](#incidents-collector). + +### Deployments collector + +Collects deployments. + +Available deployment collectors: + +- `github:deployments` (default) +- `github:deploymentWorkflowRuns` + +For more information on the collectors above, see deployment collectors details in [scorecard-backend-module-github README](../../../scorecard-backend-module-github/README.md). + +**Important:** These collectors, even the default one, require that you have `@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-github` installed. + +#### Deployments collector contract + +If you're implementing a custom _Deployments_ collector, it must adhere to the following contract: + +Required input: + +- `from: string` (ISO datetime) +- `to: string` (ISO datetime) + +Required output: + +- `deployments: Array<{ id: string; commitSha: string; environment?: string; createdAt: string; result: 'success' | 'failure' | '' }>` + +Ordering requirement: + +- `deployments` must be in ascending `createdAt` order (oldest to newest). Order is required because the metric processes adjacent deployment pairs chronologically. + +### Incidents collector + +Collects incidents in a time window. + +Available incidents collectors: + +- `jira:incidents` (default) + +For more information on the collector above, see incident collector details in [scorecard-backend-module-jira README](../../../scorecard-backend-module-jira/README.md). + +**Important:** This collector requires that you have `@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-jira` installed. + +Required entity annotations for the default `jira:incidents` collector: + +- `jira/incident-project-key` (preferred), or +- `jira/project-key` (fallback when `jira/incident-project-key` is not set) + +Optional incident-only filters: + +- `jira/incident-component` +- `jira/incident-label` +- `jira/incident-team` +- `jira/incident-issue-type` (overrides app-config `scorecard.metricProviders.dora.changeFailureRate.options.collectors.incidents.input.issueType`; default issue type is `Incident`) + +#### Incidents collector contract + +Required input: + +- `from: string` (ISO datetime) +- `to: string` (ISO datetime) + +Required output: + +- `incidents: Array<{ id: string; createdAt: string; resolutionAt: string | null }>` + +Collector-specific extra input fields are allowed, but they do not replace required contract fields. + +## Collector configuration + +### Use default GitHub and Jira collectors + +- Default, no need to provide configuration. + +```yaml +scorecard: + metricProviders: + dora: + changeFailureRate: + options: + collectors: + deployments: + id: github:deployments + incidents: + id: jira:incidents + # Optional: override default Incident issue type + # input: + # issueType: ServiceIncident +``` + +For more details about the `jira:incidents` collector, see the [scorecard-backend-module-jira README](../../../scorecard-backend-module-jira/README.md). + +### Use GitHub workflow runs for deployments + +When using workflow runs as the deployments source, provide `workflowName` as extra collector input. + +```yaml +scorecard: + metricProviders: + dora: + changeFailureRate: + options: + collectors: + deployments: + id: github:deploymentWorkflowRuns + input: + workflowName: Custom deployment name + incidents: + id: jira:incidents +``` + +### Use custom collectors + +```yaml +scorecard: + metricProviders: + dora: + changeFailureRate: + options: + collectors: + deployments: + id: customDatasource:deployments + input: + # optional collector-specific extra input + incidents: + id: customDatasource:incidents + input: + # optional collector-specific extra input +``` 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 new file mode 100644 index 00000000000..3572447ea78 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/docs/metrics/deployment-frequency.md @@ -0,0 +1,138 @@ +# DORA Deployment Frequency + +- **Metric ID**: `dora.deploymentFrequency` +- **Type**: Number +- **Unit**: deployments per week +- **Computation window**: 30 days + +Deployment Frequency measures how often a team successfully deploys changes to production. + +The metric counts successful deployments to production (or unknown environment) over the last 30 days and normalizes that count to weekly frequency. +The result is: `(successfulProductionDeployments / 30) * 7`. + +If there are no successful production deployments in the window, the metric returns `0`. + +## Options + +Provider-specific settings are under `options`: + +```yaml +scorecard: + metricProviders: + dora: + deploymentFrequency: + options: + productionEnvironments: + - production + - prod + collectors: + deployments: + id: github:deployments +``` + +- `productionEnvironments` + - Default: `['production']` + - Matching is case-insensitive; a deployment counts if its environment matches **any** configured name + - Missing/unknown `environment` still counts as production +- `collectors` — see [Collectors](#collectors) + +## Default thresholds + +Thresholds are applied to the computed `deployments/week` value: + +- `elite`: `>=7` +- `medium`: `1-7` +- `low`: `<1` + +Configure thresholds via: + +- `scorecard.metricProviders.dora.deploymentFrequency.thresholds` + +## Collectors + +DORA module uses [**collectors**](../scorecard-backend/docs/collectors.md) – reusable components designed to gather data from various datasources, such as Jira or GitHub. You can create your custom data collector to tailor data collection for your specific setup. + +This metric requires [deployments collector](#deployments-collector). + +### Deployments collector + +Collects deployments. + +Available deployment collectors: + +- `github:deployments` (default) +- `github:deploymentWorkflowRuns` + +For more information on the collectors above, see deployment collectors details in [scorecard-backend-module-github README](../../../scorecard-backend-module-github/README.md). + +**Important:** These collectors, even the default one, require that you have `@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-github` installed. + +Required entity annotations for default GitHub deployment collectors: + +```yaml +metadata: + annotations: + github.com/project-slug: myorg/my-service +``` + +#### Deployments collector contract + +If you're implementing a custom _Deployments_ collector, it must adhere to the following contract: + +Required input: + +- `from: string` (ISO datetime) +- `to: string` (ISO datetime) + +Required output: + +- `deployments: Array<{ id: string; commitSha: string; environment?: string; createdAt: string; result: 'success' | 'failure' | '' }>` + +## Collector configuration + +### Use GitHub deployments collector (default) + +- Default, no need to provide configuration. + +```yaml +scorecard: + metricProviders: + dora: + deploymentFrequency: + options: + collectors: + deployments: + id: github:deployments +``` + +### Use GitHub deployment workflow runs collector + +When using workflow runs, provide `workflowName` as extra collector input. + +```yaml +scorecard: + metricProviders: + dora: + deploymentFrequency: + options: + collectors: + deployments: + id: github:deploymentWorkflowRuns + input: + workflowName: Custom deployment +``` + +### Use custom deployments collector + +```yaml +scorecard: + metricProviders: + dora: + deploymentFrequency: + options: + collectors: + deployments: + id: customDatasource:deployments + input: + # optional collector-specific extra input +``` 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 new file mode 100644 index 00000000000..7da1c297d8c --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/docs/metrics/mean-time-to-restore.md @@ -0,0 +1,113 @@ +# DORA Mean Time to Restore + +- **Metric ID**: `dora.meanTimeToRestore` +- **Type**: Number +- **Unit**: hours +- **Computation window**: 30 days + +Mean Time to Restore measures how quickly service is restored after incidents occur. + +The metric computes mean incident recovery time for incidents in the last 30 days. +Only resolved incidents are considered (`resolutionAt` is not `null`). +For each resolved incident, recovery time is `resolutionAt - createdAt` in hours. +The result is: `mean(recoveryHours)`. + +If there are no incidents, or only unresolved ones, calculation fails with an error. +If resolved incidents exist but none have a measurable recovery time (for example `resolutionAt` before `createdAt`), calculation fails with an error. + +## Default thresholds + +Thresholds are applied to the computed value in hours: + +- `elite`: `<1` +- `medium`: `1-24` +- `low`: `>24` + +Configure thresholds via: + +- `scorecard.metricProviders.dora.meanTimeToRestore.thresholds` + +## Collectors + +DORA module uses [**collectors**](../../../scorecard-backend/docs/collectors.md) - reusable components designed to gather data from various datasources, such as Jira or GitHub. You can create your custom data collector to tailor data collection for your specific setup. + +This metric requires [Incidents collector](#incidents-collector). + +### Incidents collector + +Collects incidents in a time window. + +Available incidents collectors: + +- `jira:incidents` (default) + +For more information on the collector above, see incident collector details in [scorecard-backend-module-jira README](../../../scorecard-backend-module-jira/README.md). + +**Important:** This collector requires that you have `@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-jira` installed. + +Required entity annotations for the default `jira:incidents` collector: + +- `jira/incident-project-key` (preferred), or +- `jira/project-key` (fallback when `jira/incident-project-key` is not set) + +Optional incident-only filters: + +- `jira/incident-component` +- `jira/incident-label` +- `jira/incident-team` +- `jira/incident-issue-type` (overrides app-config `scorecard.metricProviders.dora.meanTimeToRestore.options.collectors.incidents.input.issueType`; default issue type is `Incident`) + +#### Incidents collector contract + +If you're implementing a custom _Incidents_ collector, it must adhere to the following contract: + +Required input: + +- `from: string` (ISO datetime) +- `to: string` (ISO datetime) + +Required output: + +- `incidents: Array<{ id: string; createdAt: string; resolutionAt: string | null }>` + +`createdAt` must be a valid ISO datetime. +`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. + +## Collector configuration + +### Use default Jira incidents collector + +- Default, no need to provide configuration. + +```yaml +scorecard: + metricProviders: + dora: + meanTimeToRestore: + options: + collectors: + incidents: + id: jira:incidents + # Optional: override default Incident issue type + # input: + # issueType: ServiceIncident +``` + +For more details about the `jira:incidents` collector, see the [scorecard-backend-module-jira README](../../../scorecard-backend-module-jira/README.md). + +### Use custom incidents collector + +```yaml +scorecard: + metricProviders: + dora: + meanTimeToRestore: + options: + collectors: + incidents: + id: customDatasource:incidents + input: + # optional collector-specific extra input +``` 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 new file mode 100644 index 00000000000..5d0efa400c4 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/docs/metrics/median-lead-time-for-changes.md @@ -0,0 +1,193 @@ +# DORA Median Lead Time for Changes + +- **Metric ID**: `dora.medianLeadTimeForChanges` +- **Type**: Number +- **Unit**: hours +- **Computation window**: 30 days + +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. +For each pull request in that range, lead time is `currentDeployment.createdAt - pullRequest.firstCommitAt` in hours. +The result is: `median(leadTimeHours)`. + +## Scope and limitation + +This metric assumes deployments form a single chronological stream for the entity. If deployments from multiple branches or release trains are mixed in the same stream, `previousDeployment` and `currentDeployment` can belong to different branches, which may produce incorrect lead-time pairing and noisy results. + +If fewer than two successful production deployments exist in the window, or no pull requests with a measurable lead time are found between deployments, calculation fails with an error for now. + +## Options + +Provider-specific settings are under `options`: + +```yaml +scorecard: + metricProviders: + dora: + medianLeadTimeForChanges: + options: + productionEnvironments: + - production + - prod + collectors: + deployments: + id: github:deployments + deploymentPullRequests: + id: github:deploymentPullRequests +``` + +- `productionEnvironments` + - Default: `['production']` + - Matching is case-insensitive; a deployment counts if its environment matches **any** configured name + - Missing/unknown `environment` still counts as production +- `collectors` — see [Collectors](#collectors) + +## Default thresholds + +Thresholds are applied to the computed value in hours: + +- `elite`: `<24` +- `medium`: `24-168` +- `low`: `>168` + +Configure thresholds via: + +- `scorecard.metricProviders.dora.medianLeadTimeForChanges.thresholds` + +## Collectors + +DORA module uses [**collectors**](../../../scorecard-backend/docs/collectors.md) - reusable components designed to gather data from various datasources, such as Jira or GitHub. You can create your custom data collector to tailor data collection for your specific setup. + +This metric requires two collectors: [Deployments collector](#deployments-collector) and [Pull requests between commits collector](#pull-requests-between-commits-collector). + +### Deployments collector + +Collects deployments. + +Available deployment collectors: + +- `github:deployments` (default) +- `github:deploymentWorkflowRuns` + +For more information on the collectors above, see deployment collectors details in [scorecard-backend-module-github README](../../../scorecard-backend-module-github/README.md). + +**Important:** These collectors, even the default one, require that you have `@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-github` installed. + +Required entity annotations for default GitHub deployment collectors: + +```yaml +metadata: + annotations: + github.com/project-slug: myorg/my-service +``` + +#### Deployments collector contract + +If you're implementing a custom _Deployments_ collector, it must adhere to the following contract: + +Required input: + +- `from: string` (ISO datetime) +- `to: string` (ISO datetime) + +Required output: + +- `deployments: Array<{ id: string; commitSha: string; environment?: string; createdAt: string; result: 'success' | 'failure' | '' }>` + +Ordering requirement: + +- `deployments` must be in ascending `createdAt` order (oldest to newest). Order is required because the metric processes adjacent deployment pairs chronologically. + +### Pull requests between commits collector + +Collects pull requests included in the commit range between two deployments (`baseCommitSha` -> `headCommitSha`) and provides their first commit timestamps for lead-time calculation. + +Available pull request collectors: + +- `github:deploymentPullRequests` (default) + +For more information on the collector above, see collector details in [scorecard-backend-module-github README](../../../scorecard-backend-module-github/README.md). + +**Important:** This collector requires that you have `@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-github` installed. + +Required entity annotations for default `github:deploymentPullRequests` collector: + +```yaml +metadata: + annotations: + github.com/project-slug: myorg/my-service +``` + +#### Pull requests between commits collector contract + +Required input: + +- `baseCommitSha: string` (non-empty) +- `headCommitSha: string` (non-empty) + +Required output: + +- `pullRequests: Array<{ id: string; firstCommitAt: string }>` + +`firstCommitAt` must be a valid ISO datetime for lead-time calculation. + +Collector-specific extra input fields are allowed, but they do not replace required contract fields. + +## Collector configuration + +### Use default GitHub collectors + +- Default, no need to provide configuration. + +```yaml +scorecard: + metricProviders: + dora: + medianLeadTimeForChanges: + options: + collectors: + deployments: + id: github:deployments + deploymentPullRequests: + id: github:deploymentPullRequests +``` + +### Use GitHub workflow runs for deployments + +When using workflow runs as the deployments source, provide `workflowName` as extra collector input. + +```yaml +scorecard: + metricProviders: + dora: + medianLeadTimeForChanges: + options: + collectors: + deployments: + id: github:deploymentWorkflowRuns + input: + workflowName: deploy.yml + deploymentPullRequests: + id: github:deploymentPullRequests +``` + +### Use custom collectors + +```yaml +scorecard: + metricProviders: + dora: + medianLeadTimeForChanges: + options: + collectors: + deployments: + id: customDatasource:deployments + input: + # optional collector-specific extra input + deploymentPullRequests: + id: customDatasource:deploymentPullRequests + input: + # optional collector-specific extra input +``` diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/package.json b/workspaces/scorecard/plugins/scorecard-backend-module-dora/package.json new file mode 100644 index 00000000000..76255a4b41f --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/package.json @@ -0,0 +1,71 @@ +{ + "name": "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-dora", + "version": "0.0.0", + "license": "Apache-2.0", + "description": "The dora backend module for the scorecard plugin.", + "main": "src/index.ts", + "types": "src/index.ts", + "publishConfig": { + "access": "public" + }, + "backstage": { + "role": "backend-plugin-module", + "pluginId": "scorecard", + "pluginPackage": "@red-hat-developer-hub/backstage-plugin-scorecard-backend" + }, + "configSchema": "config.d.ts", + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "package.json": [ + "package.json" + ] + } + }, + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "start": "backstage-cli package start", + "test": "NODE_OPTIONS='--experimental-vm-modules' backstage-cli package test", + "tsc": "tsc", + "prettier:check": "prettier --ignore-unknown --check .", + "prettier:fix": "prettier --ignore-unknown --write ." + }, + "dependencies": { + "@backstage/backend-plugin-api": "^1.9.2", + "@backstage/catalog-client": "^1.16.0", + "@backstage/catalog-model": "^1.9.0", + "@backstage/config": "^1.3.8", + "@backstage/types": "^1.2.2", + "@red-hat-developer-hub/backstage-plugin-scorecard-common": "workspace:^", + "@red-hat-developer-hub/backstage-plugin-scorecard-node": "workspace:^", + "zod": "^3.22.4" + }, + "devDependencies": { + "@backstage/backend-test-utils": "^1.11.4", + "@backstage/catalog-model": "^1.9.0", + "@backstage/cli": "^0.36.3" + }, + "files": [ + "config.d.ts", + "dist" + ], + "repository": { + "type": "git", + "url": "https://github.com/redhat-developer/rhdh-plugins", + "directory": "workspaces/scorecard/plugins/scorecard-backend-module-dora" + }, + "keywords": [ + "backstage", + "plugin" + ], + "homepage": "https://red.ht/rhdh", + "bugs": "https://github.com/redhat-developer/rhdh-plugins/issues", + "author": "Red Hat" +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/report.api.md b/workspaces/scorecard/plugins/scorecard-backend-module-dora/report.api.md new file mode 100644 index 00000000000..f28aee029b8 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/report.api.md @@ -0,0 +1,11 @@ +## API Report File for "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-dora" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; + +// @public (undocumented) +const scorecardModuleDora: BackendFeature; +export default scorecardModuleDora; +``` diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/constants.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/constants.ts new file mode 100644 index 00000000000..f0fc15637bd --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/constants.ts @@ -0,0 +1,22 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID = 'github:deployments'; +export const DORA_DEFAULT_DEPLOYMENT_PULL_REQUESTS_COLLECTOR_ID = + 'github:deploymentPullRequests'; +export const DORA_DEFAULT_INCIDENTS_COLLECTOR_ID = 'jira:incidents'; +export const DORA_TIME_WINDOW_DAYS = 30; +export const DORA_DEFAULT_PRODUCTION_ENVIRONMENTS = ['production']; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/annotations.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/index.ts similarity index 75% rename from workspaces/scorecard/plugins/scorecard-backend-module-jira/src/annotations.ts rename to workspaces/scorecard/plugins/scorecard-backend-module-dora/src/index.ts index d2f4a4ce775..e8486976bf7 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/annotations.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/index.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -export enum ScorecardJiraAnnotations { - PROJECT_KEY = 'jira/project-key', - COMPONENT = 'jira/component', - LABEL = 'jira/label', - TEAM = 'jira/team', - CUSTOM_FILTER = 'jira/custom-filter', -} +/** + * The dora backend module for the scorecard plugin. + * + * @packageDocumentation + */ + +export { scorecardModuleDora as default } from './module'; 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 new file mode 100644 index 00000000000..ae2093841da --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraChangeFailureRateProvider.test.ts @@ -0,0 +1,434 @@ +/* + * 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 { ConfigReader } from '@backstage/config'; +import { DoraChangeFailureRateProvider } from './DoraChangeFailureRateProvider'; +import { + buildMockCollectorsService, + buildMockDeploymentsCollector, + buildMockIncidentsCollector, + mockEntity, +} from './__fixtures__'; +import { + DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, +} from '../constants'; +import { DEFAULT_DORA_CHANGE_FAILURE_RATE_THRESHOLDS } from './DoraConfig'; + +describe('DoraChangeFailureRateProvider', () => { + let deploymentsCollector: ReturnType; + let incidentsCollector: ReturnType; + let collectorsService: ReturnType< + typeof buildMockCollectorsService + >['collectorsService']; + let collect: ReturnType['collect']; + let provider: DoraChangeFailureRateProvider; + + beforeEach(() => { + 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], + })); + provider = DoraChangeFailureRateProvider.fromConfig(new ConfigReader({}), { + collectorsService, + }); + }); + + describe('fromConfig', () => { + it('should create provider with default thresholds on metric', () => { + const metrics = provider.getMetrics(); + expect(metrics).toHaveLength(1); + expect(metrics[0].thresholds).toEqual( + DEFAULT_DORA_CHANGE_FAILURE_RATE_THRESHOLDS, + ); + expect(metrics[0].defaultVisualization).toBe('sparkline'); + }); + }); + + describe('calculateMetrics', () => { + it('should use default collectors when no config', async () => { + await provider.calculateMetrics(mockEntity); + + expect(collect).toHaveBeenCalledWith( + expect.objectContaining({ + collectorId: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + }), + ); + expect(collect).toHaveBeenCalledWith( + expect.objectContaining({ + collectorId: DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, + }), + ); + }); + + 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: { + metricProviders: { + dora: { + changeFailureRate: { + options: { + collectors: { + deployments: { + id: customDeploymentsCollectorId, + input: { + customDeploymentsInputLabel: + 'deployments-custom-input', + }, + }, + incidents: { + id: customIncidentsCollectorId, + input: { + customIncidentsInputLabel: 'incidents-custom-input', + }, + }, + }, + }, + }, + }, + }, + }, + }), + { + collectorsService: customCollectorsService, + }, + ); + + await customProvider.calculateMetrics(mockEntity); + + expect(customCollect).toHaveBeenCalledWith( + expect.objectContaining({ + collectorId: customDeploymentsCollectorId, + input: expect.objectContaining({ + from: expect.any(String), + to: expect.any(String), + customDeploymentsInputLabel: 'deployments-custom-input', + }), + }), + ); + expect(customCollect).toHaveBeenCalledWith( + expect.objectContaining({ + collectorId: customIncidentsCollectorId, + input: expect.objectContaining({ + from: expect.any(String), + to: expect.any(String), + customIncidentsInputLabel: 'incidents-custom-input', + }), + }), + ); + }); + + it('should calculate change failure rate using incidents between successful deployments', async () => { + jest.mocked(deploymentsCollector.collect).mockResolvedValueOnce({ + deployments: [ + { + id: '100', + commitSha: 'sha-1', + environment: 'production', + createdAt: '2026-06-10T00:00:00.000Z', + result: 'success', + }, + { + id: '101', + commitSha: 'sha-2', + environment: 'production', + createdAt: '2026-06-11T00:00:00.000Z', + result: 'success', + }, + { + id: '102', + commitSha: 'sha-3', + environment: 'production', + createdAt: '2026-06-12T00:00:00.000Z', + result: 'success', + }, + ], + }); + jest.mocked(incidentsCollector.collect).mockResolvedValueOnce({ + incidents: [ + { + id: 'INC-1', + createdAt: '2026-06-10T06:00:00.000Z', // for deployment 100 + resolutionAt: null, + }, + { + id: 'INC-2', + createdAt: '2026-06-12T05:00:00.000Z', // after last pair boundary + resolutionAt: null, + }, + ], + }); + + 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: [], + }); + + await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( + /need at least 2 successful production deployments/, + ); + expect(incidentsCollector.collect).not.toHaveBeenCalled(); + }); + + it('should throw when fewer than 2 successful deployments are found', async () => { + jest.mocked(deploymentsCollector.collect).mockResolvedValueOnce({ + deployments: [ + { + id: '100', + commitSha: 'sha-1', + environment: 'production', + createdAt: '2026-06-10T00:00:00.000Z', + result: 'success', + }, + { + id: '101', + commitSha: 'sha-2', + environment: 'production', + createdAt: '2026-06-11T00:00:00.000Z', + result: 'failure', + }, + ], + }); + + await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( + /need at least 2 successful production deployments.*found 1/, + ); + }); + + it('should throw when fewer than two production deployments are found', async () => { + jest.mocked(deploymentsCollector.collect).mockResolvedValueOnce({ + deployments: [ + { + id: '100', + commitSha: 'sha-1', + environment: 'production', + createdAt: '2026-06-10T00:00:00.000Z', + result: 'success', + }, + { + id: '101', + commitSha: 'sha-2', + environment: 'demo-test', + createdAt: '2026-06-11T00:00:00.000Z', + result: 'success', + }, + ], + }); + + await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( + /need at least 2 successful production deployments.*found 1/, + ); + }); + + it('should use configured productionEnvironments when filtering deployments', async () => { + const customProvider = DoraChangeFailureRateProvider.fromConfig( + new ConfigReader({ + scorecard: { + metricProviders: { + dora: { + changeFailureRate: { + options: { + productionEnvironments: ['prod'], + }, + }, + }, + }, + }, + }), + { + collectorsService, + }, + ); + + 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 return 0 when evaluated intervals have no incidents', async () => { + jest.mocked(incidentsCollector.collect).mockResolvedValueOnce({ + incidents: [], + }); + + const results = await provider.calculateMetrics(mockEntity); + + expect(results.get('dora.changeFailureRate')).toBe(0); + }); + + it('should attribute an incident after last successful production deployment to the following DORA interval', async () => { + jest.mocked(deploymentsCollector.collect).mockResolvedValueOnce({ + deployments: [ + { + id: '100', + commitSha: 'sha-1', + environment: 'production', + createdAt: '2026-06-10T00:00:00.000Z', + result: 'success', + }, + { + id: '101', + commitSha: 'sha-2', + environment: 'production', + createdAt: '2026-06-11T10:00:00.000Z', + result: 'success', + }, + { + id: '102', + commitSha: 'sha-3', + environment: 'production', + createdAt: '2026-06-12T00:00:00.000Z', + result: 'success', + }, + ], + }); + jest.mocked(incidentsCollector.collect).mockResolvedValueOnce({ + incidents: [ + { + id: 'INC-1', + // Belongs to [sha-2, sha-3] + createdAt: '2026-06-11T00:00:00.000Z', + resolutionAt: null, + }, + { + id: 'INC-2', + // After last successful deployment sha-3, not counted + createdAt: '2026-06-13T00:00:00.000Z', + resolutionAt: null, + }, + ], + }); + + const results = await provider.calculateMetrics(mockEntity); + + expect(results.get('dora.changeFailureRate')).toBe(50); // 1 of 2 intervals + }); + + it('should throw when all adjacent successful production deployments share createdAt', async () => { + jest.mocked(deploymentsCollector.collect).mockResolvedValueOnce({ + deployments: [ + { + id: '100', + commitSha: 'sha-1', + environment: 'production', + createdAt: '2026-06-10T00:00:00.000Z', + result: 'success', + }, + { + id: '101', + commitSha: 'sha-2', + environment: 'production', + createdAt: '2026-06-10T00:00:00.000Z', + result: 'success', + }, + ], + }); + + 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 new file mode 100644 index 00000000000..3689d9a462b --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraChangeFailureRateProvider.ts @@ -0,0 +1,195 @@ +/* + * 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 { 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 { DORA_TIME_WINDOW_DAYS } from '../constants'; +import { + deploymentsCollectorInputSchema, + deploymentsCollectorOutputSchema, +} from './schemas/deploymentSchemas'; +import { + incidentsCollectorInputSchema, + incidentsCollectorOutputSchema, +} from './schemas/incidentSchemas'; +import { + DEFAULT_DORA_CHANGE_FAILURE_RATE_THRESHOLDS, + type DoraChangeFailureRateConfig, + parseDoraChangeFailureRateConfig, +} from './DoraConfig'; +import { isSuccessfulProductionDeployment } from './utils/deploymentFilterUtils'; +import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; + +type DoraChangeFailureRateProviderOptions = { + collectorsService: ScorecardCollectorsService; + config: DoraChangeFailureRateConfig; +}; + +export class DoraChangeFailureRateProvider implements MetricProvider<'number'> { + private readonly collectorsService: ScorecardCollectorsService; + private readonly config: DoraChangeFailureRateConfig; + + private constructor(options: DoraChangeFailureRateProviderOptions) { + this.collectorsService = options.collectorsService; + this.config = options.config; + } + + static fromConfig( + config: Config, + options: { + collectorsService: ScorecardCollectorsService; + }, + ): DoraChangeFailureRateProvider { + return new DoraChangeFailureRateProvider({ + collectorsService: options.collectorsService, + config: parseDoraChangeFailureRateConfig(config), + }); + } + + getProviderDatasourceId(): string { + return 'dora'; + } + + getProviderId() { + return 'dora.changeFailureRate'; + } + + getMetrics(): Metric<'number'>[] { + return [ + { + id: this.getProviderId(), + title: 'DORA - Change Failure Rate', + description: + 'Monitors the percentage of deployments that cause a failure in production over the past 30 days. Elite performers maintain a change failure rate below 5%.', + type: 'number', + thresholds: DEFAULT_DORA_CHANGE_FAILURE_RATE_THRESHOLDS, + history: true, + defaultVisualization: 'sparkline', + }, + ]; + } + + getCatalogFilter(): Record { + return { + 'metadata.annotations.scorecard.io/dora': CATALOG_FILTER_EXISTS, + }; + } + + 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 successfulProductionDeployments = + deploymentsCollected.deployments.filter(deployment => + isSuccessfulProductionDeployment( + deployment, + this.config.productionEnvironments, + ), + ); + + if (successfulProductionDeployments.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}`, + ); + } + + 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++ + ) { + const deployment = successfulProductionDeployments[deploymentIndex]; + const nextDeployment = + successfulProductionDeployments[deploymentIndex + 1]; + const deploymentCreatedAt = new Date(deployment.createdAt).getTime(); + const nextDeploymentCreatedAt = new Date( + nextDeployment.createdAt, + ).getTime(); + if (nextDeploymentCreatedAt <= deploymentCreatedAt) { + continue; + } + + evaluatedDeployments += 1; + const hasIncident = incidentsCollected.incidents.some(incident => { + const incidentCreatedAt = new Date(incident.createdAt).getTime(); + return ( + incidentCreatedAt >= deploymentCreatedAt && + incidentCreatedAt < nextDeploymentCreatedAt + ); + }); + if (hasIncident) { + deploymentsWithIncidents += 1; + } + } + + if (evaluatedDeployments === 0) { + throw new Error( + 'Unable to calculate change failure rate: no evaluable deployment intervals were found (adjacent successful production deployments must have distinct createdAt timestamps)', + ); + } + + results.set( + this.getProviderId(), + Number( + ((deploymentsWithIncidents / evaluatedDeployments) * 100).toFixed(4), + ), + ); + return results; + } +} 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 new file mode 100644 index 00000000000..5f6c710e7a7 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraConfig.test.ts @@ -0,0 +1,250 @@ +/* + * 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 { ConfigReader } from '@backstage/config'; +import { + DORA_DEFAULT_DEPLOYMENT_PULL_REQUESTS_COLLECTOR_ID, + DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, + DORA_DEFAULT_PRODUCTION_ENVIRONMENTS, +} from '../constants'; +import { + parseDoraChangeFailureRateConfig, + parseDoraDeploymentFrequencyConfig, + parseDoraMeanTimeToRestoreConfig, + parseDoraMedianLeadTimeForChangesConfig, +} from './DoraConfig'; + +describe('DoraConfig', () => { + describe('parseDoraDeploymentFrequencyConfig', () => { + it('returns defaults when unset', () => { + expect(parseDoraDeploymentFrequencyConfig(new ConfigReader({}))).toEqual({ + deploymentsCollector: { + id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + input: {}, + }, + productionEnvironments: DORA_DEFAULT_PRODUCTION_ENVIRONMENTS, + }); + }); + + it('parses collectors and productionEnvironments', () => { + expect( + parseDoraDeploymentFrequencyConfig( + new ConfigReader({ + scorecard: { + metricProviders: { + dora: { + deploymentFrequency: { + options: { + productionEnvironments: ['prod', 'live'], + collectors: { + deployments: { + id: 'custom:deployments', + input: { workflowName: 'Deploy' }, + }, + }, + }, + }, + }, + }, + }, + }), + ), + ).toEqual({ + deploymentsCollector: { + id: 'custom:deployments', + input: { workflowName: 'Deploy' }, + }, + productionEnvironments: ['prod', 'live'], + }); + }); + + it('falls back to default productionEnvironments when empty', () => { + expect( + parseDoraDeploymentFrequencyConfig( + new ConfigReader({ + scorecard: { + metricProviders: { + dora: { + deploymentFrequency: { + options: { + productionEnvironments: [], + }, + }, + }, + }, + }, + }), + ).productionEnvironments, + ).toEqual(DORA_DEFAULT_PRODUCTION_ENVIRONMENTS); + }); + }); + + describe('parseDoraMedianLeadTimeForChangesConfig', () => { + it('returns defaults when unset', () => { + expect( + parseDoraMedianLeadTimeForChangesConfig(new ConfigReader({})), + ).toEqual({ + deploymentsCollector: { + id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + input: {}, + }, + deploymentPullRequestsCollector: { + id: DORA_DEFAULT_DEPLOYMENT_PULL_REQUESTS_COLLECTOR_ID, + input: {}, + }, + productionEnvironments: DORA_DEFAULT_PRODUCTION_ENVIRONMENTS, + }); + }); + + it('parses collectors and productionEnvironments', () => { + expect( + parseDoraMedianLeadTimeForChangesConfig( + new ConfigReader({ + scorecard: { + metricProviders: { + dora: { + medianLeadTimeForChanges: { + options: { + productionEnvironments: ['prod'], + collectors: { + deployments: { + id: 'custom:deployments', + input: { flag: true }, + }, + deploymentPullRequests: { + id: 'custom:deployment-prs', + input: { label: 'prs' }, + }, + }, + }, + }, + }, + }, + }, + }), + ), + ).toEqual({ + deploymentsCollector: { + id: 'custom:deployments', + input: { flag: true }, + }, + deploymentPullRequestsCollector: { + id: 'custom:deployment-prs', + input: { label: 'prs' }, + }, + productionEnvironments: ['prod'], + }); + }); + }); + + describe('parseDoraMeanTimeToRestoreConfig', () => { + it('returns defaults when unset', () => { + expect(parseDoraMeanTimeToRestoreConfig(new ConfigReader({}))).toEqual({ + incidentsCollector: { + id: DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, + input: {}, + }, + }); + }); + + it('parses incidents collector', () => { + expect( + parseDoraMeanTimeToRestoreConfig( + new ConfigReader({ + scorecard: { + metricProviders: { + dora: { + meanTimeToRestore: { + options: { + collectors: { + incidents: { + id: 'custom:incidents', + input: { project: 'OPS' }, + }, + }, + }, + }, + }, + }, + }, + }), + ), + ).toEqual({ + incidentsCollector: { + id: 'custom:incidents', + input: { project: 'OPS' }, + }, + }); + }); + }); + + describe('parseDoraChangeFailureRateConfig', () => { + it('returns defaults when unset', () => { + expect(parseDoraChangeFailureRateConfig(new ConfigReader({}))).toEqual({ + deploymentsCollector: { + id: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + input: {}, + }, + incidentsCollector: { + id: DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, + input: {}, + }, + productionEnvironments: DORA_DEFAULT_PRODUCTION_ENVIRONMENTS, + }); + }); + + it('parses collectors and productionEnvironments', () => { + expect( + parseDoraChangeFailureRateConfig( + new ConfigReader({ + scorecard: { + metricProviders: { + dora: { + changeFailureRate: { + options: { + productionEnvironments: ['prod', 'live'], + collectors: { + deployments: { + id: 'custom:deployments', + input: { workflowName: 'Deploy' }, + }, + incidents: { + id: 'custom:incidents', + input: { project: 'OPS' }, + }, + }, + }, + }, + }, + }, + }, + }), + ), + ).toEqual({ + deploymentsCollector: { + id: 'custom:deployments', + input: { workflowName: 'Deploy' }, + }, + incidentsCollector: { + id: 'custom:incidents', + input: { project: 'OPS' }, + }, + productionEnvironments: ['prod', 'live'], + }); + }); + }); +}); 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 new file mode 100644 index 00000000000..b6113c891bd --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraConfig.ts @@ -0,0 +1,271 @@ +/* + * 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 { Config } from '@backstage/config'; +import { + CollectorConfig, + ScorecardThresholdRuleColors, + ThresholdConfig, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { + DORA_DEFAULT_DEPLOYMENT_PULL_REQUESTS_COLLECTOR_ID, + DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, + DORA_DEFAULT_PRODUCTION_ENVIRONMENTS, +} from '../constants'; +import type { JsonValue } from '@backstage/types'; + +export type DoraDeploymentFrequencyConfig = { + deploymentsCollector: CollectorConfig; + productionEnvironments: string[]; +}; + +export type DoraMedianLeadTimeForChangesConfig = { + deploymentsCollector: CollectorConfig; + deploymentPullRequestsCollector: CollectorConfig; + productionEnvironments: string[]; +}; + +export type DoraMeanTimeToRestoreConfig = { + incidentsCollector: CollectorConfig; +}; + +export type DoraChangeFailureRateConfig = { + deploymentsCollector: CollectorConfig; + incidentsCollector: CollectorConfig; + productionEnvironments: string[]; +}; + +export const DEFAULT_DORA_DEPLOYMENT_FREQUENCY_THRESHOLDS: ThresholdConfig = + // Calculated metric is deployments/week from a 30-day window + { + rules: [ + { + key: 'elite', + expression: '>=7', + color: ScorecardThresholdRuleColors.SUCCESS, + icon: 'scorecardSuccessStatusIcon', + }, + { + key: 'medium', + expression: '1-7', + color: ScorecardThresholdRuleColors.WARNING, + icon: 'scorecardWarningStatusIcon', + }, + { + key: 'low', + expression: '<1', + color: ScorecardThresholdRuleColors.ERROR, + icon: 'scorecardErrorStatusIcon', + }, + ], + }; + +export const DEFAULT_DORA_MEDIAN_LEAD_TIME_THRESHOLDS: ThresholdConfig = + // Calculated metric is in hours from a 30-day window + { + rules: [ + { + key: 'elite', + expression: '<24', + color: ScorecardThresholdRuleColors.SUCCESS, + icon: 'scorecardSuccessStatusIcon', + }, + { + key: 'medium', + expression: '24-168', + color: ScorecardThresholdRuleColors.WARNING, + icon: 'scorecardWarningStatusIcon', + }, + { + key: 'low', + expression: '>168', + color: ScorecardThresholdRuleColors.ERROR, + icon: 'scorecardErrorStatusIcon', + }, + ], + }; + +export const DEFAULT_DORA_CHANGE_FAILURE_RATE_THRESHOLDS: ThresholdConfig = + // Calculated metric is in percentage + { + rules: [ + { + key: 'elite', + expression: '<5', + color: ScorecardThresholdRuleColors.SUCCESS, + icon: 'scorecardSuccessStatusIcon', + }, + { + key: 'medium', + expression: '5-15', + color: ScorecardThresholdRuleColors.WARNING, + icon: 'scorecardWarningStatusIcon', + }, + { + key: 'low', + expression: '>15', + color: ScorecardThresholdRuleColors.ERROR, + icon: 'scorecardErrorStatusIcon', + }, + ], + }; + +export const DEFAULT_DORA_MEAN_TIME_TO_RESTORE_THRESHOLDS: ThresholdConfig = + // Calculated metric is in hours + { + rules: [ + { + key: 'elite', + expression: '<1', + color: ScorecardThresholdRuleColors.SUCCESS, + icon: 'scorecardSuccessStatusIcon', + }, + { + key: 'medium', + expression: '1-24', + color: ScorecardThresholdRuleColors.WARNING, + icon: 'scorecardWarningStatusIcon', + }, + { + key: 'low', + expression: '>24', + color: ScorecardThresholdRuleColors.ERROR, + icon: 'scorecardErrorStatusIcon', + }, + ], + }; + +function parseCollectorConfig( + config: Config, + collectorConfigPath: string, + defaultId: string, +): CollectorConfig { + return { + id: config.getOptionalString(`${collectorConfigPath}.id`) ?? defaultId, + input: + config.getOptional>( + `${collectorConfigPath}.input`, + ) ?? {}, + }; +} + +function parseProductionEnvironments( + config: Config, + metricConfigPath: string, +): string[] { + const configured = config.getOptionalStringArray( + `${metricConfigPath}.options.productionEnvironments`, + ); + + if (!configured || configured.length === 0) { + return [...DORA_DEFAULT_PRODUCTION_ENVIRONMENTS]; + } + + return configured; +} + +/** + * Parses deployment-frequency provider config from the root Backstage config. + */ +export function parseDoraDeploymentFrequencyConfig( + config: Config, +): DoraDeploymentFrequencyConfig { + const providerConfigPath = + 'scorecard.metricProviders.dora.deploymentFrequency'; + + return { + deploymentsCollector: parseCollectorConfig( + config, + `${providerConfigPath}.options.collectors.deployments`, + DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + ), + productionEnvironments: parseProductionEnvironments( + config, + providerConfigPath, + ), + }; +} + +/** + * Parses median-lead-time-for-changes provider config from the root Backstage config. + */ +export function parseDoraMedianLeadTimeForChangesConfig( + config: Config, +): DoraMedianLeadTimeForChangesConfig { + const providerConfigPath = + 'scorecard.metricProviders.dora.medianLeadTimeForChanges'; + + return { + deploymentsCollector: parseCollectorConfig( + config, + `${providerConfigPath}.options.collectors.deployments`, + DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + ), + deploymentPullRequestsCollector: parseCollectorConfig( + config, + `${providerConfigPath}.options.collectors.deploymentPullRequests`, + DORA_DEFAULT_DEPLOYMENT_PULL_REQUESTS_COLLECTOR_ID, + ), + productionEnvironments: parseProductionEnvironments( + config, + providerConfigPath, + ), + }; +} + +/** + * Parses mean-time-to-restore provider config from the root Backstage config. + */ +export function parseDoraMeanTimeToRestoreConfig( + config: Config, +): DoraMeanTimeToRestoreConfig { + const providerConfigPath = 'scorecard.metricProviders.dora.meanTimeToRestore'; + + return { + incidentsCollector: parseCollectorConfig( + config, + `${providerConfigPath}.options.collectors.incidents`, + DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, + ), + }; +} + +/** + * Parses change-failure-rate provider config from the root Backstage config. + */ +export function parseDoraChangeFailureRateConfig( + config: Config, +): DoraChangeFailureRateConfig { + const providerConfigPath = 'scorecard.metricProviders.dora.changeFailureRate'; + + return { + deploymentsCollector: parseCollectorConfig( + config, + `${providerConfigPath}.options.collectors.deployments`, + DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + ), + incidentsCollector: parseCollectorConfig( + config, + `${providerConfigPath}.options.collectors.incidents`, + DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, + ), + productionEnvironments: parseProductionEnvironments( + config, + providerConfigPath, + ), + }; +} 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 new file mode 100644 index 00000000000..8de2510e831 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraDeploymentFrequencyProvider.test.ts @@ -0,0 +1,236 @@ +/* + * 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 { ConfigReader } from '@backstage/config'; +import { DoraDeploymentFrequencyProvider } from './DoraDeploymentFrequencyProvider'; +import { + buildMockCollectorsService, + buildMockDeploymentsCollector, + 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], + })); + provider = DoraDeploymentFrequencyProvider.fromConfig( + new ConfigReader({}), + { + collectorsService, + }, + ); + }); + + describe('fromConfig', () => { + it('should create provider with default thresholds on metric', () => { + const metrics = provider.getMetrics(); + expect(metrics).toHaveLength(1); + expect(metrics[0].thresholds).toEqual( + DEFAULT_DORA_DEPLOYMENT_FREQUENCY_THRESHOLDS, + ); + expect(metrics[0].defaultVisualization).toBe('sparkline'); + }); + }); + + describe('calculateMetrics', () => { + it('should use default collectors when no config', async () => { + await provider.calculateMetrics(mockEntity); + expect(collect).toHaveBeenCalledWith( + expect.objectContaining({ + collectorId: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + input: expect.objectContaining({ + from: expect.any(String), + to: expect.any(String), + }), + }), + ); + }); + + 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({ + scorecard: { + metricProviders: { + dora: { + deploymentFrequency: { + options: { + collectors: { + deployments: { + id: customCollectorId, + input: { + artificialLabel: 'frequency-test', + }, + }, + }, + }, + }, + }, + }, + }, + }), + { + collectorsService: customCollectorsService, + }, + ); + + await customProvider.calculateMetrics(mockEntity); + + expect(customCollect).toHaveBeenCalledWith( + expect.objectContaining({ + collectorId: customCollectorId, + input: expect.objectContaining({ + from: expect.any(String), + to: expect.any(String), + 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', + }, + ], + }); + + const results = await provider.calculateMetrics(mockEntity); + + expect(results.get('dora.deploymentFrequency')).toBe(0.4667); // (2 successful deployments / 30 days) * 7 + }); + + it('returns 0 when no deployments are collected', async () => { + (deploymentsCollector.collect as jest.Mock).mockResolvedValueOnce({ + deployments: [], + }); + + const results = await provider.calculateMetrics(mockEntity); + + expect(results.get('dora.deploymentFrequency')).toBe(0); + }); + + it('should treat configured productionEnvironments as production', async () => { + const customProvider = DoraDeploymentFrequencyProvider.fromConfig( + new ConfigReader({ + scorecard: { + metricProviders: { + dora: { + deploymentFrequency: { + options: { + productionEnvironments: ['prod', 'live'], + }, + }, + }, + }, + }, + }), + { + collectorsService, + }, + ); + + (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 + expect(results.get('dora.deploymentFrequency')).toBe(0.4667); + }); + }); +}); 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 new file mode 100644 index 00000000000..71bb838f29c --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraDeploymentFrequencyProvider.ts @@ -0,0 +1,132 @@ +/* + * 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 { 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 { DORA_TIME_WINDOW_DAYS } from '../constants'; +import { + deploymentsCollectorInputSchema, + deploymentsCollectorOutputSchema, +} from './schemas/deploymentSchemas'; +import { + DEFAULT_DORA_DEPLOYMENT_FREQUENCY_THRESHOLDS, + type DoraDeploymentFrequencyConfig, + parseDoraDeploymentFrequencyConfig, +} from './DoraConfig'; +import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; +import { isSuccessfulProductionDeployment } from './utils/deploymentFilterUtils'; + +type DoraDeploymentFrequencyProviderOptions = { + collectorsService: ScorecardCollectorsService; + config: DoraDeploymentFrequencyConfig; +}; + +export class DoraDeploymentFrequencyProvider + implements MetricProvider<'number'> +{ + private readonly collectorsService: ScorecardCollectorsService; + private readonly config: DoraDeploymentFrequencyConfig; + + private constructor(options: DoraDeploymentFrequencyProviderOptions) { + this.collectorsService = options.collectorsService; + this.config = options.config; + } + + static fromConfig( + config: Config, + options: { + collectorsService: ScorecardCollectorsService; + }, + ): DoraDeploymentFrequencyProvider { + return new DoraDeploymentFrequencyProvider({ + collectorsService: options.collectorsService, + config: parseDoraDeploymentFrequencyConfig(config), + }); + } + + getProviderDatasourceId(): string { + return 'dora'; + } + + getProviderId() { + return 'dora.deploymentFrequency'; + } + + getMetrics(): Metric<'number'>[] { + return [ + { + id: this.getProviderId(), + title: 'DORA - Deployment Frequency', + description: + 'Tracks how often code is successfully deployed to production over the past 30 days. Elite performers deploy on demand (multiple times per day).', + type: 'number', + thresholds: DEFAULT_DORA_DEPLOYMENT_FREQUENCY_THRESHOLDS, + history: true, + defaultVisualization: 'sparkline', + }, + ]; + } + + getCatalogFilter(): Record { + return { + 'metadata.annotations.scorecard.io/dora': CATALOG_FILTER_EXISTS, + }; + } + + 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(), + }, + }); + + if (deploymentsCollected.deployments.length === 0) { + results.set(this.getProviderId(), 0); + return results; + } + + const deployments = deploymentsCollected.deployments.filter(deployment => + isSuccessfulProductionDeployment( + deployment, + this.config.productionEnvironments, + ), + ); + + 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 new file mode 100644 index 00000000000..a151e6137be --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMeanTimeToRestoreProvider.test.ts @@ -0,0 +1,239 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mockServices } from '@backstage/backend-test-utils'; +import { ConfigReader } from '@backstage/config'; +import { DoraMeanTimeToRestoreProvider } from './DoraMeanTimeToRestoreProvider'; +import { + buildMockCollectorsService, + buildMockIncidentsCollector, + 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']; + 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], + })); + provider = DoraMeanTimeToRestoreProvider.fromConfig(new ConfigReader({}), { + collectorsService, + logger: mockLogger, + }); + }); + + describe('fromConfig', () => { + it('should create provider with default thresholds on metric', () => { + const metrics = provider.getMetrics(); + expect(metrics).toHaveLength(1); + expect(metrics[0].thresholds).toEqual( + DEFAULT_DORA_MEAN_TIME_TO_RESTORE_THRESHOLDS, + ); + expect(metrics[0].defaultVisualization).toBe('sparkline'); + }); + }); + + describe('calculateMetrics', () => { + it('should use default collector when no config', async () => { + await provider.calculateMetrics(mockEntity); + + expect(collect).toHaveBeenCalledWith( + expect.objectContaining({ + collectorId: DORA_DEFAULT_INCIDENTS_COLLECTOR_ID, + input: expect.objectContaining({ + from: expect.any(String), + to: expect.any(String), + }), + }), + ); + }); + + it('should use custom collector and pass custom inputs', async () => { + const customIncidentsCollectorId = 'custom:incidents'; + const customIncidentsCollector = buildMockIncidentsCollector({ + incidents: [ + { + id: 'INC-2', + createdAt: '2026-06-10T10:00:00.000Z', + resolutionAt: '2026-06-10T12:00:00.000Z', + }, + ], + collectorId: customIncidentsCollectorId, + }); + const { + collectorsService: customCollectorsService, + collect: customCollect, + } = buildMockCollectorsService({ + collectors: [customIncidentsCollector], + }); + const customProvider = DoraMeanTimeToRestoreProvider.fromConfig( + new ConfigReader({ + scorecard: { + metricProviders: { + dora: { + meanTimeToRestore: { + options: { + collectors: { + incidents: { + id: customIncidentsCollectorId, + input: { + customIncidentsInputLabel: 'incidents-custom-input', + }, + }, + }, + }, + }, + }, + }, + }, + }), + { + collectorsService: customCollectorsService, + logger: mockLogger, + }, + ); + + await customProvider.calculateMetrics(mockEntity); + + expect(customCollect).toHaveBeenCalledWith( + expect.objectContaining({ + collectorId: customIncidentsCollectorId, + input: expect.objectContaining({ + from: expect.any(String), + to: expect.any(String), + customIncidentsInputLabel: 'incidents-custom-input', + }), + }), + ); + }); + + it('should calculate mean time to restore in hours', async () => { + jest.mocked(incidentsCollector.collect).mockResolvedValueOnce({ + incidents: [ + { + id: 'INC-1', + createdAt: '2026-06-10T10:00:00.000Z', + resolutionAt: '2026-06-10T11:00:00.000Z', // 1h + }, + { + id: 'INC-2', + createdAt: '2026-06-11T10:00:00.000Z', + resolutionAt: '2026-06-11T12:00:00.000Z', // 2h + }, + { + id: 'INC-3', + createdAt: '2026-06-12T10:00:00.000Z', + resolutionAt: '2026-06-12T16:00:00.000Z', // 6h + }, + ], + }); + + const results = await provider.calculateMetrics(mockEntity); + + expect(results.get('dora.meanTimeToRestore')).toBe(3); + }); + + 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, + }, + ], + }); + + await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( + 'Unable to calculate mean time to restore: no resolved incidents with measurable recovery time were found', + ); + }); + + it('should throw when no incidents are found', async () => { + jest.mocked(incidentsCollector.collect).mockResolvedValueOnce({ + incidents: [], + }); + + await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( + 'Unable to calculate mean time to restore: no resolved incidents with measurable recovery time were found', + ); + }); + + 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', + }, + ], + }); + + await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( + /resolutionAt before createdAt and no measurable recovery times/, + ); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Skipping incident INC-1'), + ); + }); + + 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 + }, + ], + }); + + const results = await provider.calculateMetrics(mockEntity); + + expect(results.get('dora.meanTimeToRestore')).toBe(2); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Skipping incident INC-1'), + ); + }); + }); +}); 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 new file mode 100644 index 00000000000..70dc32a609f --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMeanTimeToRestoreProvider.ts @@ -0,0 +1,162 @@ +/* + * 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 { 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 { DORA_TIME_WINDOW_DAYS } from '../constants'; +import { + incidentsCollectorInputSchema, + incidentsCollectorOutputSchema, +} from './schemas/incidentSchemas'; +import { calculateMean } from './utils/calculationUtils'; +import { + DEFAULT_DORA_MEAN_TIME_TO_RESTORE_THRESHOLDS, + type DoraMeanTimeToRestoreConfig, + parseDoraMeanTimeToRestoreConfig, +} from './DoraConfig'; +import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; + +type DoraMeanTimeToRestoreProviderOptions = { + collectorsService: ScorecardCollectorsService; + config: DoraMeanTimeToRestoreConfig; + logger: LoggerService; +}; + +export class DoraMeanTimeToRestoreProvider implements MetricProvider<'number'> { + private readonly collectorsService: ScorecardCollectorsService; + private readonly config: DoraMeanTimeToRestoreConfig; + private readonly logger: LoggerService; + + private constructor(options: DoraMeanTimeToRestoreProviderOptions) { + this.collectorsService = options.collectorsService; + this.config = options.config; + this.logger = options.logger; + } + + static fromConfig( + config: Config, + options: { + collectorsService: ScorecardCollectorsService; + logger: LoggerService; + }, + ): DoraMeanTimeToRestoreProvider { + return new DoraMeanTimeToRestoreProvider({ + collectorsService: options.collectorsService, + config: parseDoraMeanTimeToRestoreConfig(config), + logger: options.logger, + }); + } + + getProviderDatasourceId(): string { + return 'dora'; + } + + getProviderId() { + return 'dora.meanTimeToRestore'; + } + + getMetrics(): Metric<'number'>[] { + return [ + { + id: this.getProviderId(), + title: 'DORA - Mean Time to Restore', + description: + 'Tracks the average time to restore service after an incident over the past 30 days. Elite performers restore service in under one hour.', + type: 'number', + thresholds: DEFAULT_DORA_MEAN_TIME_TO_RESTORE_THRESHOLDS, + history: true, + defaultVisualization: 'sparkline', + }, + ]; + } + + getCatalogFilter(): Record { + return { + 'metadata.annotations.scorecard.io/dora': CATALOG_FILTER_EXISTS, + }; + } + + 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 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(), + }, + }); + + const recoveryHours: number[] = []; + let invalidResolvedIncidents = 0; + for (const incident of incidentsCollected.incidents) { + if (!incident.resolutionAt) { + continue; + } + const createdAtTimestamp = new Date(incident.createdAt).getTime(); + const resolutionAtTimestamp = new Date(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})`, + ); + continue; + } + recoveryHours.push( + (resolutionAtTimestamp - createdAtTimestamp) / 3_600_000, + ); + } + + if (recoveryHours.length === 0) { + if (invalidResolvedIncidents > 0) { + throw new Error( + `Unable to calculate mean time to restore: found ${invalidResolvedIncidents} resolved incident(s) with resolutionAt before createdAt and no measurable recovery times`, + ); + } + throw new Error( + 'Unable to calculate mean time to restore: no resolved incidents with measurable recovery time were found', + ); + } + + results.set( + this.getProviderId(), + Number(calculateMean(recoveryHours).toFixed(4)), + ); + return results; + } +} 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 new file mode 100644 index 00000000000..7b82b40731c --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMedianLeadTimeForChangesProvider.test.ts @@ -0,0 +1,444 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mockServices } from '@backstage/backend-test-utils'; +import { ConfigReader } from '@backstage/config'; +import { + buildMockDeploymentsCollector, + buildMockDeploymentPullRequestsCollector, + buildMockCollectorsService, + mockEntity, +} from './__fixtures__'; +import { DoraMedianLeadTimeForChangesProvider } from './DoraMedianLeadTimeForChangesProvider'; +import { + DORA_DEFAULT_DEPLOYMENT_PULL_REQUESTS_COLLECTOR_ID, + DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, +} from '../constants'; +import { Deployment } from './schemas/deploymentSchemas'; +import { PullRequest } from './schemas/pullRequestSchemas'; +import { DEFAULT_DORA_MEDIAN_LEAD_TIME_THRESHOLDS } from './DoraConfig'; + +const mockLogger = mockServices.logger.mock(); + +describe('DoraMedianLeadTimeForChangesProvider', () => { + const deployments: Deployment[] = [ + { + id: '100', + commitSha: 'sha-previous', + environment: 'production', + createdAt: '2026-06-06T12:00:00.000Z', + result: 'success', + }, + { + id: '101', + commitSha: 'sha-current', + environment: 'production', + createdAt: '2026-06-08T12:00:00.000Z', + result: 'success', + }, + ]; + const pullRequests: PullRequest[] = [ + { + id: '123', + firstCommitAt: '2026-06-05T12:00:00.000Z', // 72h from sha-current createdAt + }, + { + id: '124', + firstCommitAt: '2026-06-07T12:00:00.000Z', // 24h from sha-current createdAt + }, + ]; + + 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({ + pullRequests, + collectorId: DORA_DEFAULT_DEPLOYMENT_PULL_REQUESTS_COLLECTOR_ID, + }); + ({ collectorsService, collect } = buildMockCollectorsService({ + collectors: [deploymentsCollector, deploymentPullRequestsCollector], + })); + provider = DoraMedianLeadTimeForChangesProvider.fromConfig( + new ConfigReader({}), + { + collectorsService, + logger: mockLogger, + }, + ); + }); + + describe('fromConfig', () => { + it('should create provider with default thresholds on metric', () => { + const metrics = provider.getMetrics(); + expect(metrics).toHaveLength(1); + expect(metrics[0].thresholds).toEqual( + DEFAULT_DORA_MEDIAN_LEAD_TIME_THRESHOLDS, + ); + expect(metrics[0].defaultVisualization).toBe('sparkline'); + }); + }); + + describe('calculateMetrics', () => { + it('should use default collectors when no config', async () => { + await provider.calculateMetrics(mockEntity); + expect(collect).toHaveBeenCalledWith( + expect.objectContaining({ + collectorId: DORA_DEFAULT_DEPLOYMENTS_COLLECTOR_ID, + input: expect.objectContaining({ + from: expect.any(String), + to: expect.any(String), + }), + }), + ); + expect(collect).toHaveBeenCalledWith( + expect.objectContaining({ + collectorId: DORA_DEFAULT_DEPLOYMENT_PULL_REQUESTS_COLLECTOR_ID, + input: expect.objectContaining({ + baseCommitSha: 'sha-previous', + headCommitSha: 'sha-current', + }), + }), + ); + }); + + it('should use custom collectors and pass custom inputs', async () => { + 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: { + metricProviders: { + dora: { + medianLeadTimeForChanges: { + options: { + collectors: { + deployments: { + id: customDeploymentsCollectorId, + input: { + artificialDeploymentFlag: true, + customDeploymentsInputLabel: + 'deployments-custom-input', + }, + }, + deploymentPullRequests: { + id: customDeploymentPullRequestsCollectorId, + input: { + artificialPullRequestsLabel: 'prs-custom-input', + }, + }, + }, + }, + }, + }, + }, + }, + }), + { + collectorsService: customCollectorsService, + logger: mockLogger, + }, + ); + + await customProvider.calculateMetrics(mockEntity); + + expect(customCollect).toHaveBeenCalledTimes(2); + expect(customCollect).toHaveBeenCalledWith( + expect.objectContaining({ + collectorId: customDeploymentsCollectorId, + input: expect.objectContaining({ + from: expect.any(String), + to: expect.any(String), + artificialDeploymentFlag: true, + customDeploymentsInputLabel: 'deployments-custom-input', + }), + }), + ); + expect(customCollect).toHaveBeenCalledWith( + expect.objectContaining({ + collectorId: customDeploymentPullRequestsCollectorId, + input: expect.objectContaining({ + baseCommitSha: 'sha-previous', + headCommitSha: 'sha-current', + artificialPullRequestsLabel: 'prs-custom-input', + }), + }), + ); + }); + + it('should calculate median lead time for changes', async () => { + const results = await provider.calculateMetrics(mockEntity); + + expect(results.get('dora.medianLeadTimeForChanges')).toBe(48); + }); + + 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 + }); + + const results = await provider.calculateMetrics(mockEntity); + + expect(results.get('dora.medianLeadTimeForChanges')).toBe(12); + expect(deploymentPullRequestsCollector.collect).toHaveBeenCalledTimes(2); + expect(deploymentPullRequestsCollector.collect).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + input: expect.objectContaining({ + baseCommitSha: 'sha-1', + headCommitSha: 'sha-2', + }), + }), + ); + expect(deploymentPullRequestsCollector.collect).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + input: expect.objectContaining({ + 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: [], + }); + + await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( + /need at least 2 successful production deployments/, + ); + expect(deploymentPullRequestsCollector.collect).not.toHaveBeenCalled(); + }); + + it('should use configured productionEnvironments when filtering deployments', async () => { + const customProvider = DoraMedianLeadTimeForChangesProvider.fromConfig( + new ConfigReader({ + scorecard: { + metricProviders: { + dora: { + medianLeadTimeForChanges: { + options: { + productionEnvironments: ['prod'], + }, + }, + }, + }, + }, + }), + { + 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', + }, + ], + }); + + await expect(customProvider.calculateMetrics(mockEntity)).rejects.toThrow( + /need at least 2 successful production deployments.*found 1/, + ); + }); + + it('should skip failed deployment intervals and calculate median from the rest', async () => { + jest.mocked(deploymentsCollector.collect).mockResolvedValueOnce({ + deployments: [ + { + id: '400', + commitSha: 'sha-1', + environment: 'production', + createdAt: '2026-06-10T00:00:00.000Z', + result: 'success', + }, + { + id: '401', + commitSha: 'sha-2', + environment: 'production', + createdAt: '2026-06-11T00:00:00.000Z', + result: 'success', + }, + { + id: '402', + commitSha: 'sha-3', + environment: 'production', + createdAt: '2026-06-12T00:00:00.000Z', + result: 'success', + }, + ], + }); + jest + .mocked(deploymentPullRequestsCollector.collect) + .mockRejectedValueOnce(new Error('GitHub compare failed')) + .mockResolvedValueOnce({ + pullRequests: [ + { id: '503', firstCommitAt: '2026-06-11T12:00:00.000Z' }, // 12h + ], + }); + + const results = await provider.calculateMetrics(mockEntity); + + expect(results.get('dora.medianLeadTimeForChanges')).toBe(12); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Skipping deployment interval sha-1..sha-2'), + ); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('dora.medianLeadTimeForChanges'), + ); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('GitHub compare failed'), + ); + }); + + 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 throw when all deployment intervals fail to collect pull requests', async () => { + jest + .mocked(deploymentPullRequestsCollector.collect) + .mockRejectedValue(new Error('collector unavailable')); + + await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( + /no pull requests with a measurable lead time/, + ); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining( + 'Skipping deployment interval sha-previous..sha-current', + ), + ); + }); + + 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 new file mode 100644 index 00000000000..660a00ee0f4 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/DoraMedianLeadTimeForChangesProvider.ts @@ -0,0 +1,206 @@ +/* + * 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 { 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 { DORA_TIME_WINDOW_DAYS } from '../constants'; +import { + deploymentPullRequestsCollectorInputSchema, + deploymentPullRequestsCollectorOutputSchema, +} from './schemas/pullRequestSchemas'; +import { + deploymentsCollectorInputSchema, + deploymentsCollectorOutputSchema, +} from './schemas/deploymentSchemas'; +import { calculateMedian } from './utils/calculationUtils'; +import { + DEFAULT_DORA_MEDIAN_LEAD_TIME_THRESHOLDS, + type DoraMedianLeadTimeForChangesConfig, + parseDoraMedianLeadTimeForChangesConfig, +} from './DoraConfig'; +import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; +import { isSuccessfulProductionDeployment } from './utils/deploymentFilterUtils'; + +type DoraMedianLeadTimeForChangesProviderOptions = { + collectorsService: ScorecardCollectorsService; + config: DoraMedianLeadTimeForChangesConfig; + logger: LoggerService; +}; + +export class DoraMedianLeadTimeForChangesProvider + implements MetricProvider<'number'> +{ + private readonly collectorsService: ScorecardCollectorsService; + private readonly config: DoraMedianLeadTimeForChangesConfig; + private readonly logger: LoggerService; + + private constructor(options: DoraMedianLeadTimeForChangesProviderOptions) { + this.collectorsService = options.collectorsService; + this.config = options.config; + this.logger = options.logger; + } + + static fromConfig( + config: Config, + options: { + collectorsService: ScorecardCollectorsService; + logger: LoggerService; + }, + ): DoraMedianLeadTimeForChangesProvider { + return new DoraMedianLeadTimeForChangesProvider({ + collectorsService: options.collectorsService, + config: parseDoraMedianLeadTimeForChangesConfig(config), + logger: options.logger, + }); + } + + getProviderDatasourceId(): string { + return 'dora'; + } + + getProviderId() { + return 'dora.medianLeadTimeForChanges'; + } + + getMetrics(): Metric<'number'>[] { + return [ + { + id: this.getProviderId(), + title: 'DORA - Median Lead Time for Changes', + description: + 'Measures the time from code commit to production deployment over the past 30 days. Elite performers have a lead time of less than 24 hours', + type: 'number', + thresholds: DEFAULT_DORA_MEDIAN_LEAD_TIME_THRESHOLDS, + history: true, + defaultVisualization: 'sparkline', + }, + ]; + } + + getCatalogFilter(): Record { + return { + 'metadata.annotations.scorecard.io/dora': CATALOG_FILTER_EXISTS, + }; + } + + 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(), + }, + }); + + // Deployments are expected to be returned sorted ascending by createdAt. + const deployments = deploymentsCollected.deployments.filter(deployment => + isSuccessfulProductionDeployment( + deployment, + this.config.productionEnvironments, + ), + ); + + if (deployments.length < 2) { + throw new Error( + `Unable to calculate median lead time for changes: need at least 2 successful production deployments in the last ${DORA_TIME_WINDOW_DAYS} days, found ${deployments.length}`, + ); + } + + const leadTimeHours: number[] = []; + for ( + let deploymentIndex = 1; + deploymentIndex < deployments.length; + deploymentIndex++ + ) { + 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, + }, + }); + } catch (error) { + this.logger.warn( + `Skipping deployment interval ${previousDeployment.commitSha}..${ + deployment.commitSha + } for ${stringifyEntityRef( + entity, + )} while calculating ${this.getProviderId()}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + continue; + } + + const deployedAtTimestamp = new Date(deployment.createdAt).getTime(); + for (const pullRequest of pullRequestsCollected.pullRequests) { + const firstCommitAtTimestamp = new Date( + pullRequest.firstCommitAt, + ).getTime(); + if (deployedAtTimestamp < firstCommitAtTimestamp) { + continue; + } + leadTimeHours.push( + (deployedAtTimestamp - firstCommitAtTimestamp) / 3_600_000, + ); + } + } + + if (leadTimeHours.length === 0) { + throw new Error( + 'Unable to calculate median lead time for changes: no pull requests with a measurable lead time were found between deployments', + ); + } + + const median = calculateMedian(leadTimeHours); + results.set(this.getProviderId(), Number(median.toFixed(4))); + return results; + } +} 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 new file mode 100644 index 00000000000..09ec3d30c5c --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/index.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 * from './mockCollectors'; +export * from './mockCollectorsService'; +export * from './mockEntity'; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockCollectors.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockCollectors.ts new file mode 100644 index 00000000000..56252d9b558 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockCollectors.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 { Collector } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { + Deployment, + deploymentsCollectorInputSchema, + deploymentsCollectorOutputSchema, +} from '../schemas/deploymentSchemas'; +import { + Incident, + incidentsCollectorInputSchema, + incidentsCollectorOutputSchema, +} from '../schemas/incidentSchemas'; +import { + PullRequest, + deploymentPullRequestsCollectorInputSchema, + deploymentPullRequestsCollectorOutputSchema, +} from '../schemas/pullRequestSchemas'; + +export function buildMockDeploymentsCollector(options: { + deployments: Deployment[]; + collectorId?: string; +}): Collector { + const { deployments, collectorId = 'github:deployments' } = options; + + return { + getCollectorId: () => collectorId, + getCollectorDescription: () => 'mock deployments collector', + getInputSchema: () => deploymentsCollectorInputSchema, + getOutputSchema: () => deploymentsCollectorOutputSchema, + collect: jest.fn(async () => ({ + deployments, + })), + }; +} + +export function buildMockDeploymentPullRequestsCollector(options: { + pullRequests: PullRequest[]; + collectorId?: string; +}): Collector { + const { pullRequests, collectorId = 'github:deploymentPullRequests' } = + options; + + return { + getCollectorId: () => collectorId, + getCollectorDescription: () => 'mock deployment pull requests collector', + getInputSchema: () => deploymentPullRequestsCollectorInputSchema, + getOutputSchema: () => deploymentPullRequestsCollectorOutputSchema, + collect: jest.fn(async () => ({ + pullRequests, + })), + }; +} + +export function buildMockIncidentsCollector(options: { + incidents: Incident[]; + collectorId?: string; +}): Collector { + const { incidents, collectorId = 'jira:incidents' } = options; + + return { + getCollectorId: () => collectorId, + getCollectorDescription: () => 'mock incidents collector', + getInputSchema: () => incidentsCollectorInputSchema, + getOutputSchema: () => incidentsCollectorOutputSchema, + collect: jest.fn(async () => ({ + incidents, + })), + }; +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockCollectorsService.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockCollectorsService.ts new file mode 100644 index 00000000000..bbbfe01f973 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockCollectorsService.ts @@ -0,0 +1,48 @@ +/* + * 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 { ScorecardCollectorsService } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { Collector } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; + +export function buildMockCollectorsService(options: { + collectors: Collector[]; +}): { collectorsService: ScorecardCollectorsService; collect: jest.Mock } { + const { collectors } = options; + const collectorsById = new Map( + collectors.map(collector => [collector.getCollectorId(), collector]), + ); + + const collect = jest.fn(async ({ collectorId, entity, input, contract }) => { + const collector = collectorsById.get(collectorId); + if (!collector) { + throw new Error(`Unexpected collector id "${collectorId}"`); + } + + const output = await collector.collect({ entity, input }); + return contract.outputSchema.parse(output); + }); + + const collectorsService = { + init: () => undefined, + hasCollector: (collectorId: string) => collectorsById.has(collectorId), + collect, + } as ScorecardCollectorsService; + + return { + collectorsService, + collect, + }; +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockEntity.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockEntity.ts new file mode 100644 index 00000000000..1b20e9493f2 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/__fixtures__/mockEntity.ts @@ -0,0 +1,27 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const mockEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-component', + annotations: { + 'scorecard.io/dora': 'true', + 'github.com/project-slug': 'org/repo', + }, + }, +}; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/schemas/deploymentSchemas.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/schemas/deploymentSchemas.ts new file mode 100644 index 00000000000..b7ed181e020 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/schemas/deploymentSchemas.ts @@ -0,0 +1,63 @@ +/* + * 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 { z } from 'zod'; + +export const deploymentSchema = z + .object({ + id: z.string().min(1), + commitSha: z.string().min(1), + environment: z.string().optional(), + createdAt: z.string().datetime(), + result: z.enum(['success', 'failure', '']), + }) + .passthrough(); +export type Deployment = z.infer; + +export const deploymentsCollectorInputSchema = z + .object({ + from: z.string().datetime(), + to: z.string().datetime(), + }) + .passthrough(); + +export const deploymentsCollectorOutputSchema = z + .object({ + deployments: z.array(deploymentSchema), + }) + .strict() + .superRefine((value, ctx) => { + for (let i = 1; i < value.deployments.length; i++) { + const previous = value.deployments[i - 1]; + const current = value.deployments[i]; + + if ( + new Date(current.createdAt).getTime() < + new Date(previous.createdAt).getTime() + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['deployments', i, 'createdAt'], + message: 'Deployments must be sorted in ascending order by createdAt', + }); + return; + } + } + }); + +export type DeploymentsCollectorOutput = { + deployments: Deployment[]; +}; 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 new file mode 100644 index 00000000000..0ef1ec7c6dd --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/schemas/incidentSchemas.ts @@ -0,0 +1,40 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { z } from 'zod'; + +export const incidentsCollectorInputSchema = z + .object({ + from: z.string().datetime(), + to: z.string().datetime(), + }) + .passthrough(); + +const incidentSchema = z + .object({ + id: z.string(), + createdAt: z.string().datetime(), + resolutionAt: z.string().datetime().nullable(), + }) + .passthrough(); + +export const incidentsCollectorOutputSchema = z + .object({ + incidents: z.array(incidentSchema), + }) + .strict(); + +export type Incident = z.infer; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/schemas/pullRequestSchemas.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/schemas/pullRequestSchemas.ts new file mode 100644 index 00000000000..6bf8bfefdcf --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/schemas/pullRequestSchemas.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 { z } from 'zod'; + +export const deploymentPullRequestsCollectorInputSchema = z + .object({ + baseCommitSha: z.string().min(1), + headCommitSha: z.string().min(1), + }) + .passthrough(); + +const pullRequestSchema = z + .object({ + id: z.string().min(1), + firstCommitAt: z.string().datetime(), + }) + .passthrough(); +export type PullRequest = z.infer; + +export const deploymentPullRequestsCollectorOutputSchema = z + .object({ + pullRequests: z.array(pullRequestSchema), + }) + .strict(); + +export type PullRequestsCollectorOutput = { + pullRequests: PullRequest[]; +}; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/utils/calculationUtils.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/utils/calculationUtils.test.ts new file mode 100644 index 00000000000..3ceeb946082 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/utils/calculationUtils.test.ts @@ -0,0 +1,53 @@ +/* + * 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 { calculateMean, calculateMedian } from './calculationUtils'; + +describe('calculateMedian', () => { + it('returns median for odd number of values', () => { + expect(calculateMedian([9, 3, 6])).toBe(6); + }); + + it('returns median for even number of values', () => { + expect(calculateMedian([10, 2, 4, 8])).toBe(6); + }); + + it('returns the same value for single-element input', () => { + expect(calculateMedian([7])).toBe(7); + }); + + it('throws on empty values', () => { + expect(() => calculateMedian([])).toThrow( + 'Unable to calculate median from empty values', + ); + }); +}); + +describe('calculateMean', () => { + it('returns mean for multiple values', () => { + expect(calculateMean([1, 2, 6])).toBe(3); + }); + + it('returns the same value for single-element input', () => { + expect(calculateMean([7])).toBe(7); + }); + + it('throws on empty values', () => { + expect(() => calculateMean([])).toThrow( + 'Unable to calculate mean from empty values', + ); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/utils/calculationUtils.ts b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/utils/calculationUtils.ts new file mode 100644 index 00000000000..8af6cdd7705 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/utils/calculationUtils.ts @@ -0,0 +1,34 @@ +/* + * 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 calculateMedian(values: number[]): number { + if (values.length === 0) { + throw new Error('Unable to calculate median from empty values'); + } + const sortedValues = [...values].sort((left, right) => left - right); + const middleIndex = Math.floor(sortedValues.length / 2); + + return sortedValues.length % 2 === 0 + ? (sortedValues[middleIndex - 1] + sortedValues[middleIndex]) / 2 + : sortedValues[middleIndex]; +} + +export function calculateMean(values: number[]): number { + if (values.length === 0) { + throw new Error('Unable to calculate mean from empty values'); + } + return values.reduce((sum, value) => sum + value, 0) / values.length; +} 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 new file mode 100644 index 00000000000..4d5d5acc538 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/utils/deploymentFilterUtils.test.ts @@ -0,0 +1,61 @@ +/* + * 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 { Deployment } from '../schemas/deploymentSchemas'; +import { + isProductionEnvironment, + isSuccessfulProductionDeployment, +} from './deploymentFilterUtils'; + +describe('deploymentFilterUtils', () => { + describe('isProductionEnvironment', () => { + it('treats missing environment as production', () => { + expect(isProductionEnvironment(undefined, ['production'])).toBe(true); + }); + + it('matches any configured environment name case-insensitively', () => { + expect(isProductionEnvironment('Prod', ['production', 'prod'])).toBe( + true, + ); + expect(isProductionEnvironment('staging', ['production', 'prod'])).toBe( + false, + ); + }); + }); + + 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 new file mode 100644 index 00000000000..f4608d86b4b --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/metricProviders/utils/deploymentFilterUtils.ts @@ -0,0 +1,49 @@ +/* + * 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 { Deployment } from '../schemas/deploymentSchemas'; + +/** + * Missing/unknown environment is treated as production. Named environments must + * match one of the configured production environment names (case-insensitive). + */ +export function isProductionEnvironment( + environment: string | undefined, + productionEnvironments: string[], +): boolean { + if (!environment) { + return true; + } + + const normalizedEnvironment = environment.toLowerCase(); + return productionEnvironments.some( + 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 new file mode 100644 index 00000000000..946f68646e1 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-dora/src/module.ts @@ -0,0 +1,60 @@ +/* + * 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 { + coreServices, + createBackendModule, +} from '@backstage/backend-plugin-api'; +import { + scorecardCollectorsServiceRef, + scorecardMetricsExtensionPoint, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { DoraChangeFailureRateProvider } from './metricProviders/DoraChangeFailureRateProvider'; +import { DoraDeploymentFrequencyProvider } from './metricProviders/DoraDeploymentFrequencyProvider'; +import { DoraMedianLeadTimeForChangesProvider } from './metricProviders/DoraMedianLeadTimeForChangesProvider'; +import { DoraMeanTimeToRestoreProvider } from './metricProviders/DoraMeanTimeToRestoreProvider'; + +export const scorecardModuleDora = createBackendModule({ + pluginId: 'scorecard', + moduleId: 'dora', + register(reg) { + reg.registerInit({ + deps: { + collectorsService: scorecardCollectorsServiceRef, + config: coreServices.rootConfig, + logger: coreServices.logger, + metrics: scorecardMetricsExtensionPoint, + }, + async init({ collectorsService, config, logger, metrics }) { + metrics.addMetricProvider( + DoraDeploymentFrequencyProvider.fromConfig(config, { + collectorsService, + }), + DoraMedianLeadTimeForChangesProvider.fromConfig(config, { + collectorsService, + logger, + }), + DoraMeanTimeToRestoreProvider.fromConfig(config, { + collectorsService, + logger, + }), + DoraChangeFailureRateProvider.fromConfig(config, { + collectorsService, + }), + ); + }, + }); + }, +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/README.md b/workspaces/scorecard/plugins/scorecard-backend-module-github/README.md index 8c3172cb9c9..c5b38b3c25b 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/README.md +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/README.md @@ -68,6 +68,73 @@ This metric counts all pull requests that are currently in an "open" state for t - **Metric Provider ID**: `github.openPRs` - **Type**: Number - **Datasource**: `github` +- **Unit**: open pull requests (count) + +## Collectors + +This module registers collectors to collect data from GitHub to be used by composite metric providers: + +- `scorecard-backend-module-dora`: + + - `github:deployments` + - `github:deploymentWorkflowRuns` + - `github:deploymentPullRequests` + +### Collector contracts + +Collectors in Scorecard are schema-validated at runtime. Any custom collector replacing a GitHub collector must return data that conforms to the same contract expected by consumers. + +Required entity annotations for GitHub collectors: + +```yaml +metadata: + annotations: + github.com/project-slug: myorg/my-service +``` + +`github:deployments` + +- **Input schema** + - `from: string` (ISO datetime) + - `to: string` (ISO datetime) +- **Output schema** + - `deployments: Array<{ id: string; commitSha: string; environment?: string; createdAt: string; result: 'success' | 'failure' | '' }>` +- **Annotation requirements** + - Requires `github.com/project-slug` on the entity +- **Behavior** + - Records are returned in ascending `createdAt` order (oldest to newest) + - Client-side fetch cap: at most **1000** deployments are collected per request. Pagination stops once the cap is reached, the cap keeps the most recent in-window runs + +`github:deploymentWorkflowRuns` + +- **Input schema** + - `workflowName: string` (non-empty) + - `from: string` (ISO datetime) + - `to: string` (ISO datetime) +- **Output schema** + - `deployments: Array<{ id: string; commitSha: string; environment?: string; createdAt: string; result: 'success' | 'failure' | '' }>` +- **Annotation requirements** + - Requires `github.com/project-slug` on the entity +- **Behavior** + - Records are returned in ascending `createdAt` order (oldest to newest) + - `workflowName` can match the workflow display name, the full workflow path (for example `.github/workflows/deploy.yml`), or a filename suffix (for example `deploy.yml`) + - Client-side fetch cap: at most **1000** workflow runs are collected per request. Pagination stops once the cap is reached, the cap keeps the most recent in-window runs + +`github:deploymentPullRequests` + +- **Input schema** + - `baseCommitSha: string` (non-empty) + - `headCommitSha: string` (non-empty) +- **Output schema** + - `pullRequests: Array<{ id: string; firstCommitAt: string }>` +- **Annotation requirements** + - Requires `github.com/project-slug` on the entity +- **Behavior** + - The collector resolves commits between `baseCommitSha` and `headCommitSha`, collects associated pull requests for those commits, and de-duplicates pull requests by PR number. + - `firstCommitAt` is the timestamp of the first commit returned for that pull request (Pull requests with missing `firstCommitAt` are skipped) + - Client-side fetch cap: at most **1000** commits are fetched for the `baseCommitSha...headCommitSha` compare range. Pagination stops once the cap is reached + +For a complete collector implementation guide, see [collectors.md](../scorecard-backend/docs/collectors.md). ## Default thresholds diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/package.json b/workspaces/scorecard/plugins/scorecard-backend-module-github/package.json index 4b2d030f43f..8749c59b545 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/package.json +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/package.json @@ -44,8 +44,10 @@ "@backstage/integration": "^2.0.3", "@backstage/plugin-catalog-node": "^2.2.2", "@octokit/graphql": "^9.0.1", + "@octokit/rest": "^20.1.1", "@red-hat-developer-hub/backstage-plugin-scorecard-common": "workspace:^", - "@red-hat-developer-hub/backstage-plugin-scorecard-node": "workspace:^" + "@red-hat-developer-hub/backstage-plugin-scorecard-node": "workspace:^", + "zod": "^3.22.4" }, "devDependencies": { "@backstage/backend-test-utils": "^1.11.4", diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/collectors/GithubDeploymentPullRequestsCollector.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/collectors/GithubDeploymentPullRequestsCollector.test.ts new file mode 100644 index 00000000000..1543ee8da44 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/collectors/GithubDeploymentPullRequestsCollector.test.ts @@ -0,0 +1,114 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mockServices } from '@backstage/backend-test-utils'; +import { ConfigReader } from '@backstage/config'; +import { GithubClient } from '../github/GithubClient'; +import { GithubDeploymentPullRequestsCollector } from './GithubDeploymentPullRequestsCollector'; + +describe('GithubDeploymentPullRequestsCollector', () => { + it('collects pull requests between two deployment commits', async () => { + const getCommitShasBetweenSpy = jest + .spyOn(GithubClient.prototype, 'getCommitShasBetween') + .mockResolvedValue(['sha-two', 'sha-three']); + + const getCommitsPullRequestsSpy = jest + .spyOn(GithubClient.prototype, 'getCommitsPullRequests') + .mockResolvedValue( + new Map([ + [ + 'sha-two', + [ + { + number: 100, + firstCommitAt: '2026-05-28T10:00:00.000Z', + }, + { number: 101, firstCommitAt: null }, + { + number: 102, + firstCommitAt: '2026-05-30T10:00:00.000Z', + }, + ], + ], + [ + 'sha-three', + [ + { + number: 102, + firstCommitAt: '2026-05-30T10:00:00.000Z', + }, + ], + ], + ]), + ); + const mockedLogger = mockServices.logger.mock(); + + const collector = GithubDeploymentPullRequestsCollector.fromConfig( + new ConfigReader({ + integrations: { + github: [ + { + host: 'github.com', + token: 'dummy-token', + }, + ], + }, + }), + { logger: mockedLogger }, + ); + + const result = await collector.collect({ + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'service-a', + annotations: { + 'github.com/project-slug': 'owner/repo', + 'backstage.io/source-location': 'url:https://github.com/owner/repo', + }, + }, + }, + input: { + baseCommitSha: 'sha-one', + headCommitSha: 'sha-three', + }, + }); + + expect(result).toEqual({ + pullRequests: [ + { + id: '100', + firstCommitAt: '2026-05-28T10:00:00.000Z', + }, + { + id: '102', + firstCommitAt: '2026-05-30T10:00:00.000Z', + }, + ], + }); + expect(mockedLogger.warn).toHaveBeenCalledWith( + 'Skipping pull request 101 for commit sha-two due to missing firstCommitAt', + ); + expect(getCommitShasBetweenSpy).toHaveBeenCalledTimes(1); + expect(getCommitsPullRequestsSpy).toHaveBeenCalledTimes(1); + expect(getCommitsPullRequestsSpy).toHaveBeenCalledWith( + 'https://github.com/owner/repo', + { owner: 'owner', repo: 'repo' }, + ['sha-two', 'sha-three'], + ); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/collectors/GithubDeploymentPullRequestsCollector.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/collectors/GithubDeploymentPullRequestsCollector.ts new file mode 100644 index 00000000000..bd8fcc416be --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/collectors/GithubDeploymentPullRequestsCollector.ts @@ -0,0 +1,132 @@ +/* + * 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 { Entity } from '@backstage/catalog-model'; +import { getEntitySourceLocation } from '@backstage/catalog-model'; +import type { LoggerService } from '@backstage/backend-plugin-api'; +import type { Config } from '@backstage/config'; +import type { Collector } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { z } from 'zod'; +import { GithubClient } from '../github/GithubClient'; +import { getRepositoryInformationFromEntity } from '../github/utils'; + +export class GithubDeploymentPullRequestsCollector + implements + Collector< + (typeof GithubDeploymentPullRequestsCollector)['inputSchema'], + (typeof GithubDeploymentPullRequestsCollector)['outputSchema'] + > +{ + static readonly inputSchema = z.object({ + baseCommitSha: z.string().min(1), + headCommitSha: z.string().min(1), + }); + static readonly outputSchema = z.object({ + pullRequests: z.array( + z.object({ + id: z.string().min(1), + firstCommitAt: z.string().datetime(), + }), + ), + }); + + private readonly client: GithubClient; + private readonly logger: LoggerService; + + private constructor(client: GithubClient, logger: LoggerService) { + this.client = client; + this.logger = logger; + } + + static fromConfig( + config: Config, + options: { logger: LoggerService }, + ): GithubDeploymentPullRequestsCollector { + return new GithubDeploymentPullRequestsCollector( + new GithubClient(config, options.logger), + options.logger, + ); + } + + getCollectorId(): string { + return 'github:deploymentPullRequests'; + } + + getCollectorDescription(): string { + return 'Collects pull requests linked to deployments.'; + } + + getInputSchema() { + return GithubDeploymentPullRequestsCollector.inputSchema; + } + + getOutputSchema() { + return GithubDeploymentPullRequestsCollector.outputSchema; + } + + async collect(options: { + entity: Entity; + input: z.infer< + (typeof GithubDeploymentPullRequestsCollector)['inputSchema'] + >; + }): Promise< + z.infer<(typeof GithubDeploymentPullRequestsCollector)['outputSchema']> + > { + const repository = getRepositoryInformationFromEntity(options.entity); + const { target } = getEntitySourceLocation(options.entity); + + const commitShas = await this.client.getCommitShasBetween( + target, + repository, + options.input.baseCommitSha, + options.input.headCommitSha, + ); + + const pullRequestsBySha = await this.client.getCommitsPullRequests( + target, + repository, + commitShas, + ); + + const pullRequestsById = new Map< + string, + { id: string; firstCommitAt: string } + >(); + for (const [commitSha, commitPullRequests] of pullRequestsBySha) { + for (const pullRequest of commitPullRequests) { + const pullRequestId = String(pullRequest.number); + if (pullRequestsById.has(pullRequestId)) { + continue; + } + if (!pullRequest.firstCommitAt) { + this.logger.warn( + `Skipping pull request ${pullRequestId} for commit ${commitSha} due to missing firstCommitAt`, + ); + continue; + } + + pullRequestsById.set(pullRequestId, { + id: pullRequestId, + firstCommitAt: pullRequest.firstCommitAt, + }); + } + } + + return { + pullRequests: Array.from(pullRequestsById.values()), + }; + } +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/collectors/GithubDeploymentWorkflowRunsCollector.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/collectors/GithubDeploymentWorkflowRunsCollector.test.ts new file mode 100644 index 00000000000..dbb7a46c369 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/collectors/GithubDeploymentWorkflowRunsCollector.test.ts @@ -0,0 +1,124 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mockServices } from '@backstage/backend-test-utils'; +import { ConfigReader } from '@backstage/config'; +import { GithubClient } from '../github/GithubClient'; +import { GithubDeploymentWorkflowRunsCollector } from './GithubDeploymentWorkflowRunsCollector'; + +const testEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'service-a', + annotations: { + 'github.com/project-slug': 'owner/repo', + 'backstage.io/source-location': 'url:https://github.com/owner/repo', + }, + }, +}; + +describe('GithubDeploymentWorkflowRunsCollector', () => { + let collector: GithubDeploymentWorkflowRunsCollector; + const mockedLogger = mockServices.logger.mock(); + + beforeEach(() => { + jest.clearAllMocks(); + collector = GithubDeploymentWorkflowRunsCollector.fromConfig( + new ConfigReader({ + integrations: { + github: [ + { + host: 'github.com', + token: 'dummy-token', + }, + ], + }, + }), + { logger: mockedLogger }, + ); + }); + + it('collects deployments from workflow runs with success conclusion', async () => { + const getWorkflowRunsSpy = jest + .spyOn(GithubClient.prototype, 'getWorkflowRuns') + .mockResolvedValue([ + { + id: 1, + sha: 'sha-one', + createdAt: '2026-06-02T00:00:00.000Z', + status: 'completed', + conclusion: 'success', + }, + ]); + + const result = await collector.collect({ + entity: testEntity, + input: { + workflowName: 'Custom deployment', + from: '2026-06-01T00:00:00.000Z', + to: '2026-06-08T00:00:00.000Z', + }, + }); + + expect(result).toEqual({ + deployments: [ + { + id: '1', + commitSha: 'sha-one', + createdAt: '2026-06-02T00:00:00.000Z', + result: 'success', + }, + ], + }); + expect(getWorkflowRunsSpy).toHaveBeenCalledTimes(1); + }); + + it('collects deployments from workflow runs with failure conclusion', async () => { + const getWorkflowRunsSpy = jest + .spyOn(GithubClient.prototype, 'getWorkflowRuns') + .mockResolvedValue([ + { + id: 2, + sha: 'sha-timeout', + createdAt: '2026-06-03T00:00:00.000Z', + status: 'completed', + conclusion: 'timed_out', + }, + ]); + + const result = await collector.collect({ + entity: testEntity, + input: { + workflowName: 'Custom deployment', + from: '2026-06-01T00:00:00.000Z', + to: '2026-06-08T00:00:00.000Z', + }, + }); + + expect(result).toEqual({ + deployments: [ + { + id: '2', + commitSha: 'sha-timeout', + createdAt: '2026-06-03T00:00:00.000Z', + result: 'failure', + }, + ], + }); + expect(getWorkflowRunsSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/collectors/GithubDeploymentWorkflowRunsCollector.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/collectors/GithubDeploymentWorkflowRunsCollector.ts new file mode 100644 index 00000000000..7c51ca065e4 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/collectors/GithubDeploymentWorkflowRunsCollector.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 type { LoggerService } from '@backstage/backend-plugin-api'; +import type { Entity } from '@backstage/catalog-model'; +import { getEntitySourceLocation } from '@backstage/catalog-model'; +import type { Config } from '@backstage/config'; +import type { Collector } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { z } from 'zod'; +import { GithubClient } from '../github/GithubClient'; +import { getRepositoryInformationFromEntity } from '../github/utils'; +import { + DeploymentResult, + deploymentsSchema, +} from './schemas/deploymentsSchemas'; + +export class GithubDeploymentWorkflowRunsCollector + implements + Collector< + (typeof GithubDeploymentWorkflowRunsCollector)['inputSchema'], + (typeof GithubDeploymentWorkflowRunsCollector)['outputSchema'] + > +{ + static readonly inputSchema = z.object({ + workflowName: z.string().min(1), + from: z.string().datetime(), + to: z.string().datetime(), + }); + static readonly outputSchema = deploymentsSchema; + + private readonly client: GithubClient; + + private constructor(client: GithubClient) { + this.client = client; + } + + static fromConfig( + config: Config, + options: { logger: LoggerService }, + ): GithubDeploymentWorkflowRunsCollector { + return new GithubDeploymentWorkflowRunsCollector( + new GithubClient(config, options.logger), + ); + } + + getCollectorId(): string { + return 'github:deploymentWorkflowRuns'; + } + + getCollectorDescription(): string { + return 'Collects deployments from GitHub Actions.'; + } + + getInputSchema() { + return GithubDeploymentWorkflowRunsCollector.inputSchema; + } + + getOutputSchema() { + return GithubDeploymentWorkflowRunsCollector.outputSchema; + } + + async collect(options: { + entity: Entity; + input: z.infer< + (typeof GithubDeploymentWorkflowRunsCollector)['inputSchema'] + >; + }): Promise< + z.infer<(typeof GithubDeploymentWorkflowRunsCollector)['outputSchema']> + > { + const repository = getRepositoryInformationFromEntity(options.entity); + const { target } = getEntitySourceLocation(options.entity); + const from = new Date(options.input.from); + const to = new Date(options.input.to); + + const workflowRuns = await this.client.getWorkflowRuns( + target, + repository, + options.input.workflowName, + from, + to, + ); + + return { + deployments: workflowRuns.map(workflowRun => ({ + id: String(workflowRun.id), + commitSha: workflowRun.sha, + createdAt: workflowRun.createdAt, + result: mapResultFromGithubConclusion(workflowRun.conclusion), + })), + }; + } +} + +function mapResultFromGithubConclusion( + conclusion: string | null, +): DeploymentResult { + if (!conclusion) { + return ''; + } + + const normalizedConclusion = conclusion.toLowerCase(); + if (normalizedConclusion === 'success') { + return 'success'; + } + if ( + ['failure', 'cancelled', 'timed_out', 'action_required'].includes( + normalizedConclusion, + ) + ) { + return 'failure'; + } + + return ''; +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/collectors/GithubDeploymentsCollector.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/collectors/GithubDeploymentsCollector.test.ts new file mode 100644 index 00000000000..6ea4ff31028 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/collectors/GithubDeploymentsCollector.test.ts @@ -0,0 +1,124 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mockServices } from '@backstage/backend-test-utils'; +import { ConfigReader } from '@backstage/config'; +import { GithubClient } from '../github/GithubClient'; +import { GithubDeploymentsCollector } from './GithubDeploymentsCollector'; + +const testEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'service-a', + annotations: { + 'github.com/project-slug': 'owner/repo', + 'backstage.io/source-location': 'url:https://github.com/owner/repo', + }, + }, +}; + +describe('GithubDeploymentsCollector', () => { + let collector: GithubDeploymentsCollector; + const mockedLogger = mockServices.logger.mock(); + + beforeEach(() => { + jest.clearAllMocks(); + collector = GithubDeploymentsCollector.fromConfig( + new ConfigReader({ + integrations: { + github: [ + { + host: 'github.com', + token: 'dummy-token', + }, + ], + }, + }), + { logger: mockedLogger }, + ); + }); + + it('collects deployments for entity and time window with success conclusion', async () => { + const getDeploymentsSpy = jest + .spyOn(GithubClient.prototype, 'getDeployments') + .mockResolvedValue([ + { + id: 1, + sha: 'sha-one', + createdAt: '2026-06-02T00:00:00.000Z', + environment: 'development', + status: 'SUCCESS', + }, + ]); + + const result = await collector.collect({ + entity: testEntity, + input: { + from: '2026-06-01T00:00:00.000Z', + to: '2026-06-08T00:00:00.000Z', + }, + }); + + expect(result).toEqual({ + deployments: [ + { + id: '1', + commitSha: 'sha-one', + environment: 'development', + createdAt: '2026-06-02T00:00:00.000Z', + result: 'success', + }, + ], + }); + expect(getDeploymentsSpy).toHaveBeenCalledTimes(1); + }); + + it('collects deployments for entity and time window with failure conclusion', async () => { + const getDeploymentsSpy = jest + .spyOn(GithubClient.prototype, 'getDeployments') + .mockResolvedValue([ + { + id: 2, + sha: 'sha-two', + createdAt: '2026-06-03T00:00:00.000Z', + environment: 'production', + status: 'ERROR', + }, + ]); + + const result = await collector.collect({ + entity: testEntity, + input: { + from: '2026-06-01T00:00:00.000Z', + to: '2026-06-08T00:00:00.000Z', + }, + }); + + expect(result).toEqual({ + deployments: [ + { + id: '2', + commitSha: 'sha-two', + environment: 'production', + createdAt: '2026-06-03T00:00:00.000Z', + result: 'failure', + }, + ], + }); + expect(getDeploymentsSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/collectors/GithubDeploymentsCollector.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/collectors/GithubDeploymentsCollector.ts new file mode 100644 index 00000000000..4f9cdd30796 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/collectors/GithubDeploymentsCollector.ts @@ -0,0 +1,114 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { LoggerService } from '@backstage/backend-plugin-api'; +import type { Entity } from '@backstage/catalog-model'; +import { getEntitySourceLocation } from '@backstage/catalog-model'; +import type { Config } from '@backstage/config'; +import type { Collector } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { z } from 'zod'; +import { GithubClient } from '../github/GithubClient'; +import { getRepositoryInformationFromEntity } from '../github/utils'; +import { + DeploymentResult, + deploymentsSchema, +} from './schemas/deploymentsSchemas'; + +export class GithubDeploymentsCollector + implements + Collector< + (typeof GithubDeploymentsCollector)['inputSchema'], + (typeof GithubDeploymentsCollector)['outputSchema'] + > +{ + static readonly inputSchema = z.object({ + from: z.string().datetime(), + to: z.string().datetime(), + }); + static readonly outputSchema = deploymentsSchema; + + private readonly client: GithubClient; + + private constructor(client: GithubClient) { + this.client = client; + } + + static fromConfig( + config: Config, + options: { logger: LoggerService }, + ): GithubDeploymentsCollector { + return new GithubDeploymentsCollector( + new GithubClient(config, options.logger), + ); + } + + getCollectorId(): string { + return 'github:deployments'; + } + + getCollectorDescription(): string { + return 'Collects GitHub deployments.'; + } + + getInputSchema() { + return GithubDeploymentsCollector.inputSchema; + } + + getOutputSchema() { + return GithubDeploymentsCollector.outputSchema; + } + + async collect(options: { + entity: Entity; + input: z.infer<(typeof GithubDeploymentsCollector)['inputSchema']>; + }): Promise> { + const repository = getRepositoryInformationFromEntity(options.entity); + const { target } = getEntitySourceLocation(options.entity); + const from = new Date(options.input.from); + const to = new Date(options.input.to); + + const deployments = await this.client.getDeployments( + target, + repository, + from, + to, + ); + + return { + deployments: deployments.map(deployment => ({ + id: String(deployment.id), + commitSha: deployment.sha, + environment: deployment.environment ?? undefined, + createdAt: deployment.createdAt, + result: mapResultFromGithubStatus(deployment.status), + })), + }; + } +} + +function mapResultFromGithubStatus(status: string | null): DeploymentResult { + if (!status) { + return ''; + } + const normalizedStatus = status.toLowerCase(); + if (normalizedStatus === 'success') { + return 'success'; + } + if (['failure', 'error'].includes(normalizedStatus)) { + return 'failure'; + } + return ''; +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/collectors/schemas/deploymentsSchemas.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/collectors/schemas/deploymentsSchemas.ts new file mode 100644 index 00000000000..538829af52f --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/collectors/schemas/deploymentsSchemas.ts @@ -0,0 +1,33 @@ +/* + * 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 { z } from 'zod'; + +export const deploymentResultSchema = z.enum(['success', 'failure', '']); + +export const deploymentSchema = z.object({ + id: z.string().min(1), + commitSha: z.string().min(1), + environment: z.string().optional(), + createdAt: z.string().datetime(), + result: deploymentResultSchema, +}); + +export type DeploymentResult = z.infer; + +export const deploymentsSchema = z.object({ + deployments: z.array(deploymentSchema), +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GitHubClient.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GitHubClient.test.ts index 578fac9ca04..5bd1ed9cf2e 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GitHubClient.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GitHubClient.test.ts @@ -14,12 +14,17 @@ * limitations under the License. */ +import { mockServices } from '@backstage/backend-test-utils'; import { ConfigReader } from '@backstage/config'; import { DefaultGithubCredentialsProvider } from '@backstage/integration'; import { GithubClient } from './GithubClient'; import { GithubRepository } from './types'; const mockedGraphqlClient = jest.fn(); +const mockedListRepoWorkflows = jest.fn(); +const mockedListWorkflowRuns = jest.fn(); +const mockedCompareCommitsWithBasehead = jest.fn(); +const mockedPaginate = jest.fn(); jest.mock('@octokit/graphql', () => ({ graphql: { @@ -27,8 +32,28 @@ jest.mock('@octokit/graphql', () => ({ }, })); +jest.mock('@octokit/rest', () => ({ + Octokit: jest.fn().mockImplementation(() => ({ + actions: { + listRepoWorkflows: mockedListRepoWorkflows, + listWorkflowRuns: mockedListWorkflowRuns, + }, + repos: { + compareCommitsWithBasehead: mockedCompareCommitsWithBasehead, + }, + rest: { + actions: { + listRepoWorkflows: mockedListRepoWorkflows, + listWorkflowRuns: mockedListWorkflowRuns, + }, + }, + paginate: mockedPaginate, + })), +})); + describe('GithubClient', () => { let githubClient: GithubClient; + const mockedLogger = mockServices.logger.mock(); const repository: GithubRepository = { owner: 'owner', repo: 'repo', @@ -55,7 +80,7 @@ describe('GithubClient', () => { ], }, }); - githubClient = new GithubClient(mockConfig); + githubClient = new GithubClient(mockConfig, mockedLogger); }); describe('getOpenPullRequestsCount', () => { @@ -92,5 +117,851 @@ describe('GithubClient', () => { githubClient.getOpenPullRequestsCount(unknownUrl, repository), ).rejects.toThrow(`Missing GitHub integration for '${unknownUrl}'`); }); + + it('should throw when repository is not found or inaccessible', async () => { + const url = `https://github.com/owner/repo`; + mockedGraphqlClient.mockResolvedValue({ repository: null }); + + await expect( + githubClient.getOpenPullRequestsCount(url, repository), + ).rejects.toThrow( + `GitHub repository '${repository.owner}/${repository.repo}' was not found or is inaccessible`, + ); + }); + }); + + describe('getDeployments', () => { + it('should return deployments filtered by date window in ascending order', async () => { + const url = `https://github.com/owner/repo`; + const from = new Date('2026-05-01T00:00:00.000Z'); + const to = new Date('2026-05-31T23:59:59.000Z'); + mockedGraphqlClient.mockResolvedValue({ + repository: { + deployments: { + nodes: [ + { + databaseId: 102, + commitOid: 'sha-within-window-newer', + createdAt: '2026-05-20T10:00:00.000Z', + environment: 'production', + latestStatus: { state: 'SUCCESS' }, + }, + { + databaseId: 101, + commitOid: 'sha-within-window', + createdAt: '2026-05-15T10:00:00.000Z', + environment: 'production', + latestStatus: { state: 'SUCCESS' }, + }, + { + databaseId: 100, + commitOid: 'sha-outside-window', + createdAt: '2026-04-01T10:00:00.000Z', + environment: 'production', + latestStatus: { state: 'FAILURE' }, + }, + ], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + }, + }, + }); + + const deployments = await githubClient.getDeployments( + url, + repository, + from, + to, + ); + + expect(deployments).toEqual([ + { + id: 101, + sha: 'sha-within-window', + createdAt: '2026-05-15T10:00:00.000Z', + environment: 'production', + status: 'SUCCESS', + }, + { + id: 102, + sha: 'sha-within-window-newer', + createdAt: '2026-05-20T10:00:00.000Z', + environment: 'production', + status: 'SUCCESS', + }, + ]); + expect(mockedGraphqlClient).toHaveBeenCalledTimes(1); + expect(mockedGraphqlClient).toHaveBeenCalledWith( + expect.stringContaining('query getDeployments'), + expect.objectContaining({ + owner: repository.owner, + repo: repository.repo, + after: null, + }), + ); + expect(getCredentialsSpy).toHaveBeenCalledWith({ url }); + }); + + it('should stop paging once fetchItemsLimit of in-window deployments is reached', async () => { + const url = `https://github.com/owner/repo`; + const from = new Date('2026-05-01T00:00:00.000Z'); + const to = new Date('2026-05-31T23:59:59.000Z'); + + mockedGraphqlClient.mockResolvedValueOnce({ + repository: { + deployments: { + nodes: [ + { + databaseId: 103, + commitOid: 'sha-three', + createdAt: '2026-05-20T10:00:00.000Z', + environment: 'production', + latestStatus: { state: 'SUCCESS' }, + }, + { + databaseId: 102, + commitOid: 'sha-two', + createdAt: '2026-05-15T10:00:00.000Z', + environment: 'production', + latestStatus: { state: 'SUCCESS' }, + }, + ], + pageInfo: { + hasNextPage: true, + endCursor: 'cursor-1', + }, + }, + }, + }); + + const deployments = await githubClient.getDeployments( + url, + repository, + from, + to, + { fetchItemsLimit: 2 }, + ); + + expect(deployments).toEqual([ + { + id: 102, + sha: 'sha-two', + createdAt: '2026-05-15T10:00:00.000Z', + environment: 'production', + status: 'SUCCESS', + }, + { + id: 103, + sha: 'sha-three', + createdAt: '2026-05-20T10:00:00.000Z', + environment: 'production', + status: 'SUCCESS', + }, + ]); + expect(mockedGraphqlClient).toHaveBeenCalledTimes(1); + expect(mockedLogger.warn).toHaveBeenCalledWith( + 'Reached fetchItemsLimit of 2 for deployments in owner/repo; stopping fetch', + ); + }); + + it('should take only remaining items when page exceeds fetchItemsLimit', async () => { + const url = `https://github.com/owner/repo`; + const from = new Date('2026-05-01T00:00:00.000Z'); + const to = new Date('2026-05-31T23:59:59.000Z'); + + mockedGraphqlClient + .mockResolvedValueOnce({ + repository: { + deployments: { + nodes: [ + { + databaseId: 104, + commitOid: 'sha-four', + createdAt: '2026-05-25T10:00:00.000Z', + environment: 'production', + latestStatus: { state: 'SUCCESS' }, + }, + { + databaseId: 103, + commitOid: 'sha-three', + createdAt: '2026-05-20T10:00:00.000Z', + environment: 'production', + latestStatus: { state: 'SUCCESS' }, + }, + ], + pageInfo: { + hasNextPage: true, + endCursor: 'cursor-1', + }, + }, + }, + }) + .mockResolvedValueOnce({ + repository: { + deployments: { + nodes: [ + { + databaseId: 102, + commitOid: 'sha-two', + createdAt: '2026-05-15T10:00:00.000Z', + environment: 'production', + latestStatus: { state: 'SUCCESS' }, + }, + { + databaseId: 101, + commitOid: 'sha-one', + createdAt: '2026-05-10T10:00:00.000Z', + environment: 'production', + latestStatus: { state: 'SUCCESS' }, + }, + ], + pageInfo: { + hasNextPage: true, + endCursor: 'cursor-2', + }, + }, + }, + }); + + const deployments = await githubClient.getDeployments( + url, + repository, + from, + to, + { fetchItemsLimit: 3 }, + ); + + expect(deployments).toEqual([ + { + id: 102, + sha: 'sha-two', + createdAt: '2026-05-15T10:00:00.000Z', + environment: 'production', + status: 'SUCCESS', + }, + { + id: 103, + sha: 'sha-three', + createdAt: '2026-05-20T10:00:00.000Z', + environment: 'production', + status: 'SUCCESS', + }, + { + id: 104, + sha: 'sha-four', + createdAt: '2026-05-25T10:00:00.000Z', + environment: 'production', + status: 'SUCCESS', + }, + ]); + expect(mockedGraphqlClient).toHaveBeenCalledTimes(2); + expect(mockedLogger.warn).toHaveBeenCalledWith( + 'Reached fetchItemsLimit of 3 for deployments in owner/repo; stopping fetch', + ); + }); + + it('should warn when fetchItemsLimit truncates the last page', async () => { + const url = `https://github.com/owner/repo`; + const from = new Date('2026-05-01T00:00:00.000Z'); + const to = new Date('2026-05-31T23:59:59.000Z'); + + mockedGraphqlClient.mockResolvedValueOnce({ + repository: { + deployments: { + nodes: [ + { + databaseId: 103, + commitOid: 'sha-three', + createdAt: '2026-05-20T10:00:00.000Z', + environment: 'production', + latestStatus: { state: 'SUCCESS' }, + }, + { + databaseId: 102, + commitOid: 'sha-two', + createdAt: '2026-05-15T10:00:00.000Z', + environment: 'production', + latestStatus: { state: 'SUCCESS' }, + }, + { + databaseId: 101, + commitOid: 'sha-one', + createdAt: '2026-05-10T10:00:00.000Z', + environment: 'production', + latestStatus: { state: 'SUCCESS' }, + }, + ], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + }, + }, + }); + + const deployments = await githubClient.getDeployments( + url, + repository, + from, + to, + { fetchItemsLimit: 2 }, + ); + + expect(deployments).toEqual([ + { + id: 102, + sha: 'sha-two', + createdAt: '2026-05-15T10:00:00.000Z', + environment: 'production', + status: 'SUCCESS', + }, + { + id: 103, + sha: 'sha-three', + createdAt: '2026-05-20T10:00:00.000Z', + environment: 'production', + status: 'SUCCESS', + }, + ]); + expect(mockedLogger.warn).toHaveBeenCalledWith( + 'Reached fetchItemsLimit of 2 for deployments in owner/repo; stopping fetch', + ); + }); + + it('should throw when repository is not found or inaccessible', async () => { + const url = `https://github.com/owner/repo`; + mockedGraphqlClient.mockResolvedValue({ repository: null }); + + await expect( + githubClient.getDeployments( + url, + repository, + new Date('2026-05-01T00:00:00.000Z'), + new Date('2026-05-31T23:59:59.000Z'), + ), + ).rejects.toThrow( + `GitHub repository '${repository.owner}/${repository.repo}' was not found or is inaccessible`, + ); + }); + }); + + describe('getCommitsPullRequests', () => { + it('should return pull requests linked to commit shas', async () => { + const url = `https://github.com/owner/repo`; + const shaOne = '6f9cb0a3627d4f0f194f2efce2685f6f6fd7f8a1'; + const shaTwo = '122afb699853d5decd7225dee37a6bad7176b013'; + mockedGraphqlClient.mockResolvedValue({ + repository: { + commit0: { + associatedPullRequests: { + nodes: [ + { + number: 123, + commits: { + nodes: [ + { + commit: { + committedDate: '2026-05-28T08:30:00.000Z', + }, + }, + ], + }, + }, + ], + }, + }, + commit1: { + associatedPullRequests: { + nodes: [ + { + number: 456, + commits: { + nodes: [ + { + commit: { + committedDate: '2026-05-29T08:30:00.000Z', + }, + }, + ], + }, + }, + ], + }, + }, + }, + }); + + const pullRequestsBySha = await githubClient.getCommitsPullRequests( + url, + repository, + [shaOne, shaTwo], + ); + + expect(Object.fromEntries(pullRequestsBySha)).toEqual({ + [shaOne]: [ + { + number: 123, + firstCommitAt: '2026-05-28T08:30:00.000Z', + }, + ], + [shaTwo]: [ + { + number: 456, + firstCommitAt: '2026-05-29T08:30:00.000Z', + }, + ], + }); + expect(mockedGraphqlClient).toHaveBeenCalledTimes(1); + expect(mockedGraphqlClient).toHaveBeenCalledWith( + expect.stringContaining('query getCommitsPullRequests'), + expect.objectContaining({ + owner: repository.owner, + repo: repository.repo, + sha0: shaOne, + sha1: shaTwo, + }), + ); + expect(getCredentialsSpy).toHaveBeenCalledWith({ url }); + }); + + it('should throw when repository is not found or inaccessible', async () => { + const url = `https://github.com/owner/repo`; + mockedGraphqlClient.mockResolvedValue({ repository: null }); + + await expect( + githubClient.getCommitsPullRequests(url, repository, [ + '6f9cb0a3627d4f0f194f2efce2685f6f6fd7f8a1', + ]), + ).rejects.toThrow( + `GitHub repository '${repository.owner}/${repository.repo}' was not found or is inaccessible`, + ); + }); + }); + + describe('getCommitShasBetween', () => { + it('should return deduplicated commit shas across paginated compare results', async () => { + const url = `https://github.com/owner/repo`; + mockedCompareCommitsWithBasehead + .mockResolvedValueOnce({ + data: { + total_commits: 101, + commits: [{ sha: 'sha-two' }, { sha: 'sha-three' }], + }, + }) + .mockResolvedValueOnce({ + data: { + total_commits: 101, + commits: [{ sha: 'sha-three' }, { sha: 'sha-four' }], + }, + }); + + const commitShas = await githubClient.getCommitShasBetween( + url, + repository, + 'sha-one', + 'sha-four', + ); + + expect(commitShas).toEqual(['sha-two', 'sha-three', 'sha-four']); + expect(mockedCompareCommitsWithBasehead).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + owner: repository.owner, + repo: repository.repo, + basehead: 'sha-one...sha-four', + per_page: 100, + page: 1, + }), + ); + expect(mockedCompareCommitsWithBasehead).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + basehead: 'sha-one...sha-four', + per_page: 100, + page: 2, + }), + ); + expect(getCredentialsSpy).toHaveBeenCalledWith({ url }); + }); + + it('should stop paging once fetchItemsLimit of commits is reached', async () => { + const url = `https://github.com/owner/repo`; + mockedCompareCommitsWithBasehead + .mockResolvedValueOnce({ + data: { + total_commits: 250, + commits: [{ sha: 'sha-1' }, { sha: 'sha-2' }], + }, + }) + .mockResolvedValueOnce({ + data: { + total_commits: 250, + commits: [{ sha: 'sha-3' }, { sha: 'sha-4' }], + }, + }); + + const commitShas = await githubClient.getCommitShasBetween( + url, + repository, + 'sha-base', + 'sha-head', + { fetchItemsLimit: 3 }, + ); + + expect(commitShas).toEqual(['sha-1', 'sha-2', 'sha-3']); + expect(mockedCompareCommitsWithBasehead).toHaveBeenCalledTimes(2); + expect(mockedLogger.warn).toHaveBeenCalledWith( + 'Reached fetchItemsLimit of 3 for commits between sha-base...sha-head in owner/repo; stopping fetch (250 commits reported)', + ); + }); + + it('should take only remaining items when the first page exceeds fetchItemsLimit', async () => { + const url = `https://github.com/owner/repo`; + mockedCompareCommitsWithBasehead.mockResolvedValueOnce({ + data: { + total_commits: 5, + commits: [ + { sha: 'sha-1' }, + { sha: 'sha-2' }, + { sha: 'sha-3' }, + { sha: 'sha-4' }, + ], + }, + }); + + const commitShas = await githubClient.getCommitShasBetween( + url, + repository, + 'sha-base', + 'sha-head', + { fetchItemsLimit: 2 }, + ); + + expect(commitShas).toEqual(['sha-1', 'sha-2']); + expect(mockedCompareCommitsWithBasehead).toHaveBeenCalledTimes(1); + expect(mockedLogger.warn).toHaveBeenCalledWith( + 'Reached fetchItemsLimit of 2 for commits between sha-base...sha-head in owner/repo; stopping fetch (5 commits reported)', + ); + }); + + it('should take only remaining items when a later page exceeds fetchItemsLimit', async () => { + const url = `https://github.com/owner/repo`; + mockedCompareCommitsWithBasehead + .mockResolvedValueOnce({ + data: { + total_commits: 250, + commits: [{ sha: 'sha-1' }, { sha: 'sha-2' }, { sha: 'sha-3' }], + }, + }) + .mockResolvedValueOnce({ + data: { + total_commits: 250, + commits: [{ sha: 'sha-4' }, { sha: 'sha-5' }, { sha: 'sha-6' }], + }, + }); + + const commitShas = await githubClient.getCommitShasBetween( + url, + repository, + 'sha-base', + 'sha-head', + { fetchItemsLimit: 4 }, + ); + + expect(commitShas).toEqual(['sha-1', 'sha-2', 'sha-3', 'sha-4']); + expect(mockedCompareCommitsWithBasehead).toHaveBeenCalledTimes(2); + expect(mockedCompareCommitsWithBasehead).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ page: 2 }), + ); + expect(mockedLogger.warn).toHaveBeenCalledWith( + 'Reached fetchItemsLimit of 4 for commits between sha-base...sha-head in owner/repo; stopping fetch (250 commits reported)', + ); + }); + }); + + describe('getWorkflowRuns', () => { + it('should return workflow runs filtered by workflow name and date window in ascending order', async () => { + const url = `https://github.com/owner/repo`; + const from = new Date('2026-05-01T00:00:00.000Z'); + const to = new Date('2026-05-31T23:59:59.000Z'); + + mockedPaginate.mockImplementation(async (endpoint, _params, mapFn) => { + if (endpoint === mockedListRepoWorkflows) { + const data = [ + { id: 11, name: 'Deploy', path: '.github/workflows/deploy.yml' }, + { id: 22, name: 'CI', path: '.github/workflows/ci.yml' }, + ]; + return mapFn ? mapFn({ data }) : data; + } + + const data = [ + { + id: 1002, + head_sha: 'sha-two', + created_at: '2026-05-11T10:00:00.000Z', + status: null, + conclusion: null, + }, + { + id: 1001, + head_sha: 'sha-one', + created_at: '2026-05-10T10:00:00.000Z', + status: 'completed', + conclusion: 'success', + }, + ]; + return mapFn ? mapFn({ data }, () => undefined) : data; + }); + + const workflowRuns = await githubClient.getWorkflowRuns( + url, + repository, + 'Deploy', + from, + to, + ); + + expect(workflowRuns).toEqual([ + { + id: 1001, + sha: 'sha-one', + createdAt: '2026-05-10T10:00:00.000Z', + status: 'completed', + conclusion: 'success', + }, + { + id: 1002, + sha: 'sha-two', + createdAt: '2026-05-11T10:00:00.000Z', + status: null, + conclusion: null, + }, + ]); + expect(mockedPaginate).toHaveBeenNthCalledWith( + 1, + mockedListRepoWorkflows, + expect.objectContaining({ + owner: repository.owner, + repo: repository.repo, + per_page: 100, + }), + expect.any(Function), + ); + }); + + it('should stop paging once fetchItemsLimit of workflow runs is reached', async () => { + const url = `https://github.com/owner/repo`; + const from = new Date('2026-05-01T00:00:00.000Z'); + const to = new Date('2026-05-31T23:59:59.000Z'); + + mockedPaginate.mockImplementation(async (endpoint, _params, mapFn) => { + if (endpoint === mockedListRepoWorkflows) { + const data = [ + { id: 11, name: 'Deploy', path: '.github/workflows/deploy.yml' }, + ]; + return mapFn ? mapFn({ data }) : data; + } + + const pages = [ + [ + { + id: 1003, + head_sha: 'sha-three', + created_at: '2026-05-12T10:00:00.000Z', + status: 'completed', + conclusion: 'success', + }, + { + id: 1002, + head_sha: 'sha-two', + created_at: '2026-05-11T10:00:00.000Z', + status: 'completed', + conclusion: 'success', + }, + ], + [ + { + id: 1001, + head_sha: 'sha-one', + created_at: '2026-05-10T10:00:00.000Z', + status: 'completed', + conclusion: 'success', + }, + ], + ]; + + const results: unknown[] = []; + for (const data of pages) { + let stopped = false; + const mapped = mapFn + ? mapFn({ data }, () => { + stopped = true; + }) + : data; + results.push(...(Array.isArray(mapped) ? mapped : [])); + if (stopped) { + break; + } + } + return results; + }); + + const workflowRuns = await githubClient.getWorkflowRuns( + url, + repository, + 'Deploy', + from, + to, + { fetchItemsLimit: 2 }, + ); + + expect(workflowRuns).toEqual([ + { + id: 1002, + sha: 'sha-two', + createdAt: '2026-05-11T10:00:00.000Z', + status: 'completed', + conclusion: 'success', + }, + { + id: 1003, + sha: 'sha-three', + createdAt: '2026-05-12T10:00:00.000Z', + status: 'completed', + conclusion: 'success', + }, + ]); + expect(mockedPaginate).toHaveBeenCalledTimes(2); + }); + + it('should slice the last page and call done when it exceeds remaining fetchItemsLimit', async () => { + const url = `https://github.com/owner/repo`; + const from = new Date('2026-05-01T00:00:00.000Z'); + const to = new Date('2026-05-31T23:59:59.000Z'); + const doneCalls: boolean[] = []; + + mockedPaginate.mockImplementation(async (endpoint, _params, mapFn) => { + if (endpoint === mockedListRepoWorkflows) { + const data = [ + { id: 11, name: 'Deploy', path: '.github/workflows/deploy.yml' }, + ]; + return mapFn ? mapFn({ data }) : data; + } + + const pages = [ + [ + { + id: 1003, + head_sha: 'sha-three', + created_at: '2026-05-12T10:00:00.000Z', + status: 'completed', + conclusion: 'success', + }, + { + id: 1002, + head_sha: 'sha-two', + created_at: '2026-05-11T10:00:00.000Z', + status: 'completed', + conclusion: 'success', + }, + ], + [ + { + id: 1001, + head_sha: 'sha-one', + created_at: '2026-05-10T10:00:00.000Z', + status: 'completed', + conclusion: 'success', + }, + { + id: 1000, + head_sha: 'sha-zero', + created_at: '2026-05-09T10:00:00.000Z', + status: 'completed', + conclusion: 'success', + }, + ], + ]; + + const results: unknown[] = []; + for (const data of pages) { + let stopped = false; + const mapped = mapFn + ? mapFn({ data }, () => { + stopped = true; + doneCalls.push(true); + }) + : data; + results.push(...(Array.isArray(mapped) ? mapped : [])); + if (stopped) { + break; + } + } + return results; + }); + + const workflowRuns = await githubClient.getWorkflowRuns( + url, + repository, + 'Deploy', + from, + to, + { fetchItemsLimit: 3 }, + ); + + expect(workflowRuns).toEqual([ + { + id: 1001, + sha: 'sha-one', + createdAt: '2026-05-10T10:00:00.000Z', + status: 'completed', + conclusion: 'success', + }, + { + id: 1002, + sha: 'sha-two', + createdAt: '2026-05-11T10:00:00.000Z', + status: 'completed', + conclusion: 'success', + }, + { + id: 1003, + sha: 'sha-three', + createdAt: '2026-05-12T10:00:00.000Z', + status: 'completed', + conclusion: 'success', + }, + ]); + expect(doneCalls).toEqual([true]); + expect(mockedPaginate).toHaveBeenCalledTimes(2); + }); + + it('should throw when workflow cannot be resolved by name', async () => { + const url = `https://github.com/owner/repo`; + mockedPaginate.mockImplementation(async (endpoint, _params, mapFn) => { + if (endpoint === mockedListRepoWorkflows) { + const data = [ + { id: 22, name: 'CI', path: '.github/workflows/ci.yml' }, + ]; + return mapFn ? mapFn({ data }) : data; + } + return []; + }); + + await expect( + githubClient.getWorkflowRuns( + url, + repository, + 'Deploy', + new Date('2026-05-01T00:00:00.000Z'), + new Date('2026-05-31T23:59:59.000Z'), + ), + ).rejects.toThrow( + `Workflow 'Deploy' was not found in '${repository.owner}/${repository.repo}'`, + ); + }); }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GithubClient.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GithubClient.ts index c82f1b6d3d6..fb4e3e19b4d 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GithubClient.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GithubClient.ts @@ -14,19 +14,39 @@ * limitations under the License. */ +import type { LoggerService } from '@backstage/backend-plugin-api'; import type { Config } from '@backstage/config'; import { DefaultGithubCredentialsProvider, ScmIntegrations, } from '@backstage/integration'; import { graphql } from '@octokit/graphql'; -import { GithubRepository } from './types'; +import { Octokit } from '@octokit/rest'; +import { + GithubDeployment, + GithubWorkflowRun, + GithubPullRequest, + GithubRepository, + GithubDeploymentsQueryResponse, + GithubCommitsPullRequestsQueryResponse, +} from './types'; +import { + DEFAULT_DEPLOYMENT_FETCH_ITEMS_LIMIT, + GITHUB_BATCH_SIZE, +} from './constants'; +import { buildCommitsPullRequestsQuery } from './queries/buildCommitsPullRequestsQuery'; +import { mapCommitsPullRequests } from './mappers'; export class GithubClient { private readonly integrations: ScmIntegrations; + private readonly credentialsProvider: DefaultGithubCredentialsProvider; + private readonly logger: LoggerService; - constructor(config: Config) { + constructor(config: Config, logger: LoggerService) { this.integrations = ScmIntegrations.fromConfig(config); + this.credentialsProvider = + DefaultGithubCredentialsProvider.fromIntegrations(this.integrations); + this.logger = logger; } private async getOctokitClient(url: string): Promise { @@ -35,10 +55,7 @@ export class GithubClient { throw new Error(`Missing GitHub integration for '${url}'`); } - const credentialsProvider = - DefaultGithubCredentialsProvider.fromIntegrations(this.integrations); - - const { headers } = await credentialsProvider.getCredentials({ + const { headers } = await this.credentialsProvider.getCredentials({ url, }); @@ -48,6 +65,22 @@ export class GithubClient { }); } + private async getOctokitRestClient(url: string): Promise { + const githubIntegration = this.integrations.github.byUrl(url); + if (!githubIntegration) { + throw new Error(`Missing GitHub integration for '${url}'`); + } + + const { token } = await this.credentialsProvider.getCredentials({ + url, + }); + + return new Octokit({ + auth: token, + baseUrl: githubIntegration.config.apiBaseUrl, + }); + } + async getOpenPullRequestsCount( url: string, repository: GithubRepository, @@ -69,12 +102,310 @@ export class GithubClient { pullRequests: { totalCount: number; }; - }; + } | null; }>(query, { owner: repository.owner, repo: repository.repo, }); + if (!response.repository) { + throw new Error( + `GitHub repository '${repository.owner}/${repository.repo}' was not found or is inaccessible`, + ); + } + return response.repository.pullRequests.totalCount; } + + async getDeployments( + url: string, + repository: GithubRepository, + from: Date, + to: Date, + options?: { fetchItemsLimit?: number }, + ): Promise { + const fetchItemsLimit = + options?.fetchItemsLimit ?? DEFAULT_DEPLOYMENT_FETCH_ITEMS_LIMIT; + const octokit = await this.getOctokitClient(url); + const deployments: GithubDeployment[] = []; + const query = ` + query getDeployments($owner: String!, $repo: String!, $after: String) { + repository(owner: $owner, name: $repo) { + deployments( + first: ${GITHUB_BATCH_SIZE} + orderBy: { field: CREATED_AT, direction: DESC } + after: $after + ) { + nodes { + databaseId + commitOid + createdAt + environment + latestStatus { + state + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + `; + const fromTimestamp = from.getTime(); + const toTimestamp = to.getTime(); + let after: string | null = null; + let hasMorePages = true; + let reachedOlderThanWindow = false; + + while (hasMorePages && deployments.length < fetchItemsLimit) { + const response: GithubDeploymentsQueryResponse = await octokit(query, { + owner: repository.owner, + repo: repository.repo, + after: after, + }); + + if (!response.repository) { + throw new Error( + `GitHub repository '${repository.owner}/${repository.repo}' was not found or is inaccessible`, + ); + } + + const pageDeployments = response.repository.deployments?.nodes ?? []; + + if (pageDeployments.length === 0) { + break; + } + + let truncatedCurrentPage = false; + for (const deployment of pageDeployments) { + if (deployments.length >= fetchItemsLimit) { + truncatedCurrentPage = true; + break; + } + + if (!deployment || !deployment.databaseId || !deployment.commitOid) { + continue; + } + + const deployedAt = Date.parse(deployment.createdAt); + if (Number.isNaN(deployedAt)) { + continue; + } + + if (deployedAt < fromTimestamp) { + reachedOlderThanWindow = true; + } + + if (deployedAt >= fromTimestamp && deployedAt <= toTimestamp) { + deployments.push({ + id: deployment.databaseId, + sha: deployment.commitOid, + createdAt: deployment.createdAt, + environment: deployment.environment ?? null, + status: deployment.latestStatus?.state ?? null, + }); + } + } + + const githubHasNextPage = Boolean( + response.repository.deployments?.pageInfo.hasNextPage, + ); + if ( + deployments.length >= fetchItemsLimit && + (githubHasNextPage || truncatedCurrentPage) + ) { + this.logger.warn( + `Reached fetchItemsLimit of ${fetchItemsLimit} for deployments in ${repository.owner}/${repository.repo}; stopping fetch`, + ); + } + + hasMorePages = + deployments.length < fetchItemsLimit && + !reachedOlderThanWindow && + githubHasNextPage; + after = response.repository.deployments?.pageInfo.endCursor ?? null; + } + + // GitHub returns DESC by createdAt so we can stop early when outside of time range; + // normalize to ASC for chronological processing (oldest -> newest). + return deployments.reverse(); + } + + async getCommitShasBetween( + url: string, + repository: GithubRepository, + baseSha: string, + headSha: string, + options?: { fetchItemsLimit?: number }, + ): Promise { + const fetchItemsLimit = + options?.fetchItemsLimit ?? DEFAULT_DEPLOYMENT_FETCH_ITEMS_LIMIT; + const octokit = await this.getOctokitRestClient(url); + + const basehead = `${baseSha}...${headSha}`; + const commitShas: string[] = []; + + // compareCommitsWithBasehead returns a mixed object (commits/files/url), not a list endpoint, + // page manually instead of octokit.paginate because it is unable to handle typing correctly. + const firstPage = await octokit.repos.compareCommitsWithBasehead({ + owner: repository.owner, + repo: repository.repo, + basehead, + per_page: GITHUB_BATCH_SIZE, + page: 1, + }); + + const totalCommits = firstPage.data.total_commits; + const appendCommits = (commits: Array<{ sha: string }>) => { + const remaining = fetchItemsLimit - commitShas.length; + if (remaining <= 0) { + return; + } + const pageShas = commits.map(commit => commit.sha); + commitShas.push( + ...(pageShas.length > remaining + ? pageShas.slice(0, remaining) + : pageShas), + ); + }; + + appendCommits(firstPage.data.commits); + + const totalPages = Math.ceil(totalCommits / GITHUB_BATCH_SIZE); + for ( + let page = 2; + page <= totalPages && commitShas.length < fetchItemsLimit; + page++ + ) { + const response = await octokit.repos.compareCommitsWithBasehead({ + owner: repository.owner, + repo: repository.repo, + basehead, + per_page: GITHUB_BATCH_SIZE, + page, + }); + appendCommits(response.data.commits); + } + + if (totalCommits > fetchItemsLimit) { + this.logger.warn( + `Reached fetchItemsLimit of ${fetchItemsLimit} for commits between ${baseSha}...${headSha} in ${repository.owner}/${repository.repo}; stopping fetch (${totalCommits} commits reported)`, + ); + } + + return Array.from(new Set(commitShas)); + } + + async getCommitsPullRequests( + url: string, + repository: GithubRepository, + shas: string[], + ): Promise> { + const pullRequestsBySha = new Map(); + if (shas.length === 0) { + return pullRequestsBySha; + } + + const octokit = await this.getOctokitClient(url); + for (let offset = 0; offset < shas.length; offset += GITHUB_BATCH_SIZE) { + const batch = shas.slice(offset, offset + GITHUB_BATCH_SIZE); + const { query, variables } = buildCommitsPullRequestsQuery( + repository, + batch, + ); + + const response = await octokit( + query, + variables, + ); + + if (!response.repository) { + throw new Error( + `GitHub repository '${repository.owner}/${repository.repo}' was not found or is inaccessible`, + ); + } + + for (const [sha, pullRequests] of mapCommitsPullRequests( + response.repository, + batch, + )) { + pullRequestsBySha.set(sha, pullRequests); + } + } + + return pullRequestsBySha; + } + + async getWorkflowRuns( + url: string, + repository: GithubRepository, + workflowName: string, + from: Date, + to: Date, + options?: { fetchItemsLimit?: number }, + ): Promise { + const fetchItemsLimit = + options?.fetchItemsLimit ?? DEFAULT_DEPLOYMENT_FETCH_ITEMS_LIMIT; + const octokit = await this.getOctokitRestClient(url); + + const workflows = await octokit.paginate( + octokit.actions.listRepoWorkflows, + { + owner: repository.owner, + repo: repository.repo, + per_page: GITHUB_BATCH_SIZE, + }, + response => response.data, + ); + + const workflow = workflows.find( + item => + item.name === workflowName || + item.path === workflowName || + item.path.endsWith(`/${workflowName}`), + ); + + if (!workflow) { + throw new Error( + `Workflow '${workflowName}' was not found in '${repository.owner}/${repository.repo}'`, + ); + } + + const workflowRuns: GithubWorkflowRun[] = []; + await octokit.paginate( + octokit.actions.listWorkflowRuns, + { + owner: repository.owner, + repo: repository.repo, + workflow_id: workflow.id, + created: `${from.toISOString()}..${to.toISOString()}`, + per_page: GITHUB_BATCH_SIZE, + }, + (response, done) => { + const remaining = fetchItemsLimit - workflowRuns.length; + const pageRuns = + response.data.length > remaining + ? response.data.slice(0, remaining) + : response.data; + const mapped = pageRuns.map(run => ({ + id: run.id, + sha: run.head_sha, + createdAt: run.created_at, + status: run.status ?? null, + conclusion: run.conclusion ?? null, + })); + workflowRuns.push(...mapped); + if (workflowRuns.length === fetchItemsLimit) { + done(); + } + return mapped; + }, + ); + + // GitHub returns DESC by createdAt + // normalize to ASC for chronological processing (oldest -> newest). + return workflowRuns.reverse(); + } } diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/constants.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/constants.ts index c8e598fca68..be370d21a43 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/constants.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/constants.ts @@ -15,3 +15,10 @@ */ export const GITHUB_PROJECT_ANNOTATION = 'github.com/project-slug'; +export const GITHUB_BATCH_SIZE = 100; + +/** + * Default client-side cap for GitHub list/compare fetches (deployments, + * deployment workflow runs, and commits between SHAs). + */ +export const DEFAULT_DEPLOYMENT_FETCH_ITEMS_LIMIT = 1000; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/mappers.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/mappers.ts new file mode 100644 index 00000000000..555f3880717 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/mappers.ts @@ -0,0 +1,48 @@ +/* + * 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 { + GithubCommitsPullRequestsQueryResponse, + GithubPullRequest, +} from './types'; + +export function mapCommitsPullRequests( + repository: NonNullable, + shas: string[], +): Map { + const pullRequestsBySha = new Map(); + + shas.forEach((sha, index) => { + const nodes = + repository[`commit${index}`]?.associatedPullRequests?.nodes ?? []; + pullRequestsBySha.set( + sha, + nodes.flatMap(pr => + pr + ? [ + { + number: pr.number, + firstCommitAt: + pr.commits?.nodes?.[0]?.commit?.committedDate ?? null, + }, + ] + : [], + ), + ); + }); + + return pullRequestsBySha; +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/queries/buildCommitsPullRequestsQuery.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/queries/buildCommitsPullRequestsQuery.test.ts new file mode 100644 index 00000000000..c2fa073abf7 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/queries/buildCommitsPullRequestsQuery.test.ts @@ -0,0 +1,62 @@ +/* + * 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 { buildCommitsPullRequestsQuery } from './buildCommitsPullRequestsQuery'; + +describe('buildCommitsPullRequestsQuery', () => { + const repository = { owner: 'org', repo: 'test' }; + + it('builds query variables for a single commit sha', () => { + const sha = 'abc123'; + const { query, variables } = buildCommitsPullRequestsQuery(repository, [ + sha, + ]); + + expect(variables).toEqual({ + owner: 'org', + repo: 'test', + sha0: sha, + }); + expect(query).toContain( + 'query getCommitsPullRequests($owner: String!, $repo: String!, $sha0: String!)', + ); + expect(query).toContain('repository(owner: $owner, name: $repo)'); + expect(query).toContain('commit0: object(expression: $sha0)'); + expect(query).toContain('associatedPullRequests(first: 10)'); + expect(query).toContain('committedDate'); + }); + + it('builds aliased commit lookups for multiple shas', () => { + const shaOne = 'sha-one'; + const shaTwo = 'sha-two'; + const { query, variables } = buildCommitsPullRequestsQuery(repository, [ + shaOne, + shaTwo, + ]); + + expect(variables).toEqual({ + owner: 'org', + repo: 'test', + sha0: shaOne, + sha1: shaTwo, + }); + expect(query).toContain( + 'query getCommitsPullRequests($owner: String!, $repo: String!, $sha0: String!, $sha1: String!)', + ); + expect(query).toContain('commit0: object(expression: $sha0)'); + expect(query).toContain('commit1: object(expression: $sha1)'); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/queries/buildCommitsPullRequestsQuery.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/queries/buildCommitsPullRequestsQuery.ts new file mode 100644 index 00000000000..9ae2a1e14ef --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/queries/buildCommitsPullRequestsQuery.ts @@ -0,0 +1,71 @@ +/* + * 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 { GithubRepository } from '../types'; + +export function buildCommitsPullRequestsQuery( + repository: GithubRepository, + shas: string[], +): { query: string; variables: Record } { + // A commit usually has 1 associated PR; keep first: 10 for odd edge cases. + const commitAssociatedPRsSection = ` + ... on Commit { + associatedPullRequests(first: 10) { + nodes { + number + commits(first: 1) { + nodes { + commit { + committedDate + } + } + } + } + } + } +`; + + const variableDefinitions = shas + .map((_, index) => `$sha${index}: String!`) + .join(', '); + const aliasedObjects = shas + .map( + (_, index) => ` + commit${index}: object(expression: $sha${index}) { + ${commitAssociatedPRsSection} + } + `, + ) + .join('\n'); + + const query = ` + query getCommitsPullRequests($owner: String!, $repo: String!, ${variableDefinitions}) { + repository(owner: $owner, name: $repo) { + ${aliasedObjects} + } + } + `; + + const variables: Record = { + owner: repository.owner, + repo: repository.repo, + }; + shas.forEach((sha, index) => { + variables[`sha${index}`] = sha; + }); + + return { query, variables }; +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/types.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/types.ts index df73a37e945..3e7f045f05a 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/types.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/types.ts @@ -13,7 +13,72 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import { GraphQlQueryResponseData } from '@octokit/graphql'; + export type GithubRepository = { owner: string; repo: string; }; + +export type GithubDeployment = { + id: number; + sha: string; + createdAt: string; + environment: string | null; + status: string | null; +}; + +export type GithubPullRequest = { + number: number; + firstCommitAt: string | null; +}; + +export type GithubWorkflowRun = { + id: number; + sha: string; + createdAt: string; + status: string | null; + conclusion: string | null; +}; + +export type GithubDeploymentsQueryResponse = GraphQlQueryResponseData & { + repository: { + deployments: { + nodes: Array<{ + databaseId?: number | null; + commitOid?: string | null; + createdAt: string; + environment?: string | null; + latestStatus?: { + state?: string | null; + } | null; + } | null> | null; + pageInfo: { + hasNextPage: boolean; + endCursor: string | null; + }; + } | null; + } | null; +}; + +export type GithubCommitsPullRequestsQueryResponse = + GraphQlQueryResponseData & { + repository: Record< + string, + { + associatedPullRequests?: { + nodes: Array<{ + number: number; + commits?: { + nodes: Array<{ + commit?: { + committedDate?: string | null; + } | null; + } | null> | null; + } | null; + } | null> | null; + } | null; + } | null + > | null; + }; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/utils.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/utils.ts index 31bbf2de3a6..e2b45d7ae2b 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/utils.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/utils.ts @@ -17,9 +17,9 @@ import { type Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { GithubRepository } from './types'; import { GITHUB_PROJECT_ANNOTATION } from './constants'; -export const getRepositoryInformationFromEntity = ( +export function getRepositoryInformationFromEntity( entity: Entity, -): GithubRepository => { +): GithubRepository { const projectSlug = entity.metadata.annotations?.[GITHUB_PROJECT_ANNOTATION]; if (!projectSlug) { throw new Error( @@ -39,4 +39,4 @@ export const getRepositoryInformationFromEntity = ( } return { owner, repo }; -}; +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenPRsProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenPRsProvider.test.ts index 453aa81d6b9..0e63fd35f90 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenPRsProvider.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenPRsProvider.test.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { mockServices } from '@backstage/backend-test-utils'; import { ConfigReader } from '@backstage/config'; import type { Entity } from '@backstage/catalog-model'; import { GithubOpenPRsProvider } from './GithubOpenPRsProvider'; @@ -30,9 +31,13 @@ jest.mock('@backstage/catalog-model', () => ({ jest.mock('../github/GithubClient'); describe('GithubOpenPRsProvider', () => { + const mockedLogger = mockServices.logger.mock(); + describe('fromConfig', () => { it('should create provider with default thresholds on metric', () => { - const provider = GithubOpenPRsProvider.fromConfig(new ConfigReader({})); + const provider = GithubOpenPRsProvider.fromConfig(new ConfigReader({}), { + logger: mockedLogger, + }); const metrics = provider.getMetrics(); expect(metrics).toHaveLength(1); expect(metrics[0].thresholds).toEqual(DEFAULT_NUMBER_THRESHOLDS); @@ -51,7 +56,9 @@ describe('GithubOpenPRsProvider', () => { beforeEach(() => { jest.clearAllMocks(); - provider = GithubOpenPRsProvider.fromConfig(new ConfigReader({})); + provider = GithubOpenPRsProvider.fromConfig(new ConfigReader({}), { + logger: mockedLogger, + }); }); it('should calculate metric', async () => { diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenPRsProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenPRsProvider.ts index 32e481b375b..6b7933b83c3 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenPRsProvider.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenPRsProvider.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import type { LoggerService } from '@backstage/backend-plugin-api'; import type { Config } from '@backstage/config'; import { getEntitySourceLocation, type Entity } from '@backstage/catalog-model'; import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; @@ -28,8 +29,15 @@ import { getRepositoryInformationFromEntity } from '../github/utils'; export class GithubOpenPRsProvider implements MetricProvider<'number'> { private readonly githubClient: GithubClient; - private constructor(config: Config) { - this.githubClient = new GithubClient(config); + private constructor(githubClient: GithubClient) { + this.githubClient = githubClient; + } + + static fromConfig( + config: Config, + options: { logger: LoggerService }, + ): GithubOpenPRsProvider { + return new GithubOpenPRsProvider(new GithubClient(config, options.logger)); } getProviderDatasourceId(): string { @@ -60,10 +68,6 @@ export class GithubOpenPRsProvider implements MetricProvider<'number'> { }; } - static fromConfig(config: Config): GithubOpenPRsProvider { - return new GithubOpenPRsProvider(config); - } - async calculateMetrics(entity: Entity): Promise> { const repository = getRepositoryInformationFromEntity(entity); const { target } = getEntitySourceLocation(entity); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/module.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/module.ts index 267556de1c3..a7dc873eb46 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/module.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/module.ts @@ -17,7 +17,13 @@ import { coreServices, createBackendModule, } from '@backstage/backend-plugin-api'; -import { scorecardMetricsExtensionPoint } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { + scorecardCollectorsExtensionPoint, + scorecardMetricsExtensionPoint, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { GithubDeploymentPullRequestsCollector } from './collectors/GithubDeploymentPullRequestsCollector'; +import { GithubDeploymentWorkflowRunsCollector } from './collectors/GithubDeploymentWorkflowRunsCollector'; +import { GithubDeploymentsCollector } from './collectors/GithubDeploymentsCollector'; import { GithubOpenPRsProvider } from './metricProviders/GithubOpenPRsProvider'; export const scorecardModuleGithub = createBackendModule({ @@ -26,11 +32,20 @@ export const scorecardModuleGithub = createBackendModule({ register(reg) { reg.registerInit({ deps: { + collectors: scorecardCollectorsExtensionPoint, config: coreServices.rootConfig, + logger: coreServices.logger, metrics: scorecardMetricsExtensionPoint, }, - async init({ config, metrics }) { - metrics.addMetricProvider(GithubOpenPRsProvider.fromConfig(config)); + async init({ collectors, config, logger, metrics }) { + collectors.addCollector( + GithubDeploymentsCollector.fromConfig(config, { logger }), + GithubDeploymentWorkflowRunsCollector.fromConfig(config, { logger }), + GithubDeploymentPullRequestsCollector.fromConfig(config, { logger }), + ); + metrics.addMetricProvider( + GithubOpenPRsProvider.fromConfig(config, { logger }), + ); }, }); }, diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/README.md b/workspaces/scorecard/plugins/scorecard-backend-module-jira/README.md index 904bdabd58f..76f9f6175bc 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-jira/README.md +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/README.md @@ -86,11 +86,14 @@ Options define configuration that affect fetch jira issues global configuration, scorecard: metricProviders: jira: + # Scorecard-owned Jira datasource settings (auth stays under top-level jira:) openIssues: options: - # Optional: use mandatoryFilter filter if need to replaces default which is "type = Bug AND resolution = Unresolved" - mandatoryFilter: Type = Task AND Resolution = Resolved - # Optional: use to specify global customFilter, however the annotation `jira/custom-filter` will replaces them + # Optional: replaces the default mandatory filter + # ("type = Bug AND resolution = Unresolved") + mandatoryFilter: type = Task AND resolution = Resolved + # Optional: global custom filter. Overridden by entity annotation + # jira/custom-filter when that annotation is set. customFilter: priority in ("Critical", "Blocker") ``` @@ -164,7 +167,7 @@ metadata: jira/label: UI # Optional: recommended to use Jira team ID instead of team title jira/team: 9d3ea319-fb5b-4621-9dab-05fe502283e - # Optional: Custom filters for loading data request. This filter replaces customFilters form app-config.yaml + # Optional: Custom JQL; overrides app-config openIssues.options.customFilter jira/custom-filter: 'reporter = "psycon98@yahoo.com" AND resolution is not EMPTY' spec: type: website @@ -184,6 +187,66 @@ This metric counts all jira issues that match the filter condition specified in - **Type**: `Number` - **Datasource**: `jira` +## Collectors + +This module registers collectors to collect data from Jira to be used by composite metric providers: + +- `scorecard-backend-module-dora`: + + - `jira:incidents` + +### Collector contracts + +Collectors in Scorecard are schema-validated at runtime. Any custom collector replacing a Jira collector must return data that conforms to the same contract expected by consumers. + +`jira:incidents` + +- **Input schema** + - `from: string` (ISO datetime) + - `to: string` (ISO datetime) + - `issueType?: string` (optional; default `Incident`) +- **Output schema** + - `incidents: Array<{ id: string; createdAt: string; resolutionAt: string | null }>` +- **Annotation requirements** + - Uses `jira/incident-project-key` when present + - Falls back to `jira/project-key` when `jira/incident-project-key` is not set + - Requires at least one of those `project-key` annotations on the entity + - Optional incident-only filters (no fallback to open-issues annotations): + - `jira/incident-component` + - `jira/incident-label` + - `jira/incident-team` + - `jira/incident-issue-type` (overrides app-config `input.issueType` when set) +- **Behavior** + - Collects Jira issues matching the configured issue type (default `Incident`) + - Does not apply the open-issues `mandatoryFilter` / global `customFilter` from app-config + - Client-side fetch cap: at most **1000** incidents are collected per request. Pagination stops once the cap is reached + +Example entity annotations for `jira:incidents` collector: + +```yaml +# catalog-info.yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: my-service + annotations: + # Jira project for incident collection: + jira/incident-project-key: INCIDENTS + # Optional fallback when jira/incident-project-key is not set: + jira/project-key: PROJECT + # Optional incident-only filters: + # jira/incident-component: Payments + # jira/incident-label: sev-1 + # jira/incident-team: team-ops + # jira/incident-issue-type: Production Incident +spec: + type: service + lifecycle: production + owner: team-a +``` + +For a complete collector implementation guide, see [collectors.md](../scorecard-backend/docs/collectors.md). + ## Default thresholds Default thresholds for `jira.openIssues`: diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/__fixtures__/testUtils.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/__fixtures__/testUtils.ts index 90e9ab244e9..1a2a9bb6372 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-jira/__fixtures__/testUtils.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/__fixtures__/testUtils.ts @@ -66,7 +66,7 @@ export function newMockRootConfig({ }: NewMockRootConfigProps = {}): Config { const jira = { baseUrl: 'https://example.com/api', - token: 'Fds31dsF32', + token: 'dummyToken', product: 'cloud', proxyPath: '/jira/api', ...jiraConfig, diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/package.json b/workspaces/scorecard/plugins/scorecard-backend-module-jira/package.json index 50d3e005db3..51a53f0e1ce 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-jira/package.json +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/package.json @@ -43,12 +43,14 @@ "@backstage/catalog-model": "^1.9.0", "@backstage/plugin-catalog-node": "^2.2.2", "@red-hat-developer-hub/backstage-plugin-scorecard-common": "workspace:^", - "@red-hat-developer-hub/backstage-plugin-scorecard-node": "workspace:^" + "@red-hat-developer-hub/backstage-plugin-scorecard-node": "workspace:^", + "zod": "^3.22.4" }, "devDependencies": { "@backstage/backend-test-utils": "^1.11.4", "@backstage/cli": "^0.36.3", - "@backstage/config": "^1.3.8" + "@backstage/config": "^1.3.8", + "@backstage/types": "^1.2.2" }, "files": [ "config.d.ts", diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/annotations/annotationKeys.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/annotations/annotationKeys.ts new file mode 100644 index 00000000000..28baa890782 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/annotations/annotationKeys.ts @@ -0,0 +1,63 @@ +/* + * 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 { JiraFilterAnnotations } from './types'; + +export enum ScorecardJiraAnnotations { + PROJECT_KEY = 'jira/project-key', + COMPONENT = 'jira/component', + LABEL = 'jira/label', + TEAM = 'jira/team', + CUSTOM_FILTER = 'jira/custom-filter', +} + +/** + * Annotations used by the `jira:incidents` collector. + * `INCIDENT_PROJECT_KEY` falls back to {@link ScorecardJiraAnnotations.PROJECT_KEY}. + * Component, label, and team are incident-specific (no fallback). + * `INCIDENT_ISSUE_TYPE` overrides collector input `issueType` when set; + * otherwise the input value or default `Incident` is used. + */ +export enum ScorecardJiraIncidentAnnotations { + INCIDENT_PROJECT_KEY = 'jira/incident-project-key', + INCIDENT_COMPONENT = 'jira/incident-component', + INCIDENT_LABEL = 'jira/incident-label', + INCIDENT_TEAM = 'jira/incident-team', + INCIDENT_ISSUE_TYPE = 'jira/incident-issue-type', +} + +/** + * Maps open-issues JQL filter slots to {@link ScorecardJiraAnnotations}. + */ +export const OPEN_ISSUES_FILTER_ANNOTATIONS: JiraFilterAnnotations = { + project: ScorecardJiraAnnotations.PROJECT_KEY, + component: ScorecardJiraAnnotations.COMPONENT, + label: ScorecardJiraAnnotations.LABEL, + team: ScorecardJiraAnnotations.TEAM, + customFilter: ScorecardJiraAnnotations.CUSTOM_FILTER, +}; + +/** + * Maps incident JQL filter slots to {@link ScorecardJiraIncidentAnnotations} + * (except {@link ScorecardJiraIncidentAnnotations.INCIDENT_ISSUE_TYPE}, which + * is resolved by incident JQL). + */ +export const INCIDENT_FILTER_ANNOTATIONS: JiraFilterAnnotations = { + project: ScorecardJiraIncidentAnnotations.INCIDENT_PROJECT_KEY, + component: ScorecardJiraIncidentAnnotations.INCIDENT_COMPONENT, + label: ScorecardJiraIncidentAnnotations.INCIDENT_LABEL, + team: ScorecardJiraIncidentAnnotations.INCIDENT_TEAM, +}; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/annotations/buildJqlFiltersFromEntity.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/annotations/buildJqlFiltersFromEntity.test.ts new file mode 100644 index 00000000000..68b4824cab5 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/annotations/buildJqlFiltersFromEntity.test.ts @@ -0,0 +1,251 @@ +/* + * 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 { newEntityComponent } from '../../__fixtures__/testUtils'; +import { buildJqlFiltersFromEntity } from './buildJqlFiltersFromEntity'; +import { + INCIDENT_FILTER_ANNOTATIONS, + OPEN_ISSUES_FILTER_ANNOTATIONS, + ScorecardJiraAnnotations, + ScorecardJiraIncidentAnnotations, +} from './annotationKeys'; +import type { JiraFilterAnnotations } from './types'; + +const { PROJECT_KEY, COMPONENT, LABEL, TEAM, CUSTOM_FILTER } = + ScorecardJiraAnnotations; + +const { + INCIDENT_PROJECT_KEY, + INCIDENT_COMPONENT, + INCIDENT_LABEL, + INCIDENT_TEAM, + INCIDENT_ISSUE_TYPE, +} = ScorecardJiraIncidentAnnotations; + +type SharedFilterAnnotations = Pick< + Required, + 'project' | 'component' | 'label' | 'team' +>; + +describe('buildJqlFiltersFromEntity', () => { + const annotationFilterCases = [ + { + name: 'open issues', + keys: OPEN_ISSUES_FILTER_ANNOTATIONS as SharedFilterAnnotations & { + customFilter?: string; + }, + options: undefined, + missingProjectError: `Missing required '${PROJECT_KEY}' annotation for entity 'mock-entity'`, + }, + { + name: 'incidents', + keys: INCIDENT_FILTER_ANNOTATIONS as SharedFilterAnnotations, + options: { projectFallback: PROJECT_KEY }, + missingProjectError: `Missing required '${INCIDENT_PROJECT_KEY}' or '${PROJECT_KEY}' annotation for entity 'mock-entity'`, + }, + ] as const; + + it.each(annotationFilterCases)( + '$name: should extract project filter correctly when entity has only "project key"', + ({ keys, options }) => { + const entity = newEntityComponent({ [keys.project]: 'TEST' }); + const filters = buildJqlFiltersFromEntity(entity, keys, options); + + expect(filters).toEqual({ + project: 'project = "TEST"', + }); + }, + ); + + it.each(annotationFilterCases)( + '$name: should throw error for missing project key when entity is missing "project key"', + ({ keys, options, missingProjectError }) => { + const entity = newEntityComponent({}); + + expect(() => buildJqlFiltersFromEntity(entity, keys, options)).toThrow( + missingProjectError, + ); + }, + ); + + it.each(annotationFilterCases)( + '$name: should throw error for invalid "project key" when "project key" is invalid', + ({ keys, options }) => { + const entity = newEntityComponent({ [keys.project]: 'TEST$123' }); + + expect(() => buildJqlFiltersFromEntity(entity, keys, options)).toThrow( + `${keys.project} contains invalid characters. Only alphanumeric, hyphens, spaces, and underscores are allowed.`, + ); + }, + ); + + it('open issues: should extract all filters correctly when entity has all expected annotations', () => { + const entity = newEntityComponent({ + [PROJECT_KEY]: 'TEST', + [COMPONENT]: 'backend', + [LABEL]: 'critical', + [TEAM]: '4316', + [CUSTOM_FILTER]: 'priority = High', + }); + + const filters = buildJqlFiltersFromEntity( + entity, + OPEN_ISSUES_FILTER_ANNOTATIONS, + ); + + expect(filters).toEqual({ + project: 'project = "TEST"', + component: 'component = "backend"', + label: 'labels = "critical"', + team: 'team = 4316', + customFilter: 'priority = High', + }); + }); + + it('incidents: should extract all supported filters correctly when entity has all expected annotations', () => { + const entity = newEntityComponent({ + [INCIDENT_PROJECT_KEY]: 'TEST', + [INCIDENT_COMPONENT]: 'backend', + [INCIDENT_LABEL]: 'critical', + [INCIDENT_TEAM]: '4316', + }); + + const filters = buildJqlFiltersFromEntity( + entity, + INCIDENT_FILTER_ANNOTATIONS, + { projectFallback: PROJECT_KEY }, + ); + + expect(filters).toEqual({ + project: 'project = "TEST"', + component: 'component = "backend"', + label: 'labels = "critical"', + team: 'team = 4316', + }); + }); + + it.each(annotationFilterCases)( + '$name: should throw error for invalid "component" when "component" is invalid', + ({ keys, options }) => { + const entity = newEntityComponent({ + [keys.project]: 'TEST', + [keys.component]: 'backend$123', + }); + + expect(() => buildJqlFiltersFromEntity(entity, keys, options)).toThrow( + `${keys.component} contains invalid characters. Only alphanumeric, hyphens, spaces, and underscores are allowed.`, + ); + }, + ); + + it.each(annotationFilterCases)( + '$name: should throw error for invalid "label" when "label" is invalid', + ({ keys, options }) => { + const entity = newEntityComponent({ + [keys.project]: 'TEST', + [keys.label]: 'critical$123', + }); + + expect(() => buildJqlFiltersFromEntity(entity, keys, options)).toThrow( + `${keys.label} contains invalid characters. Only alphanumeric, hyphens, spaces, and underscores are allowed.`, + ); + }, + ); + + it.each(annotationFilterCases)( + '$name: should throw error for invalid "team" when "team" is invalid', + ({ keys, options }) => { + const entity = newEntityComponent({ + [keys.project]: 'TEST', + [keys.team]: 'team-alpha$123', + }); + + expect(() => buildJqlFiltersFromEntity(entity, keys, options)).toThrow( + `${keys.team} contains invalid characters. Only alphanumeric, hyphens, and underscores are allowed.`, + ); + }, + ); + + it('incidents: should fall back to project key when incident project key is missing', () => { + const entity = newEntityComponent({ + [PROJECT_KEY]: 'PROJ', + }); + + const filters = buildJqlFiltersFromEntity( + entity, + INCIDENT_FILTER_ANNOTATIONS, + { projectFallback: PROJECT_KEY }, + ); + + expect(filters).toEqual({ + project: 'project = "PROJ"', + }); + }); + + it('open issues: should apply open-issues filters and ignore incident annotations', () => { + const entity = newEntityComponent({ + [PROJECT_KEY]: 'TEST', + [COMPONENT]: 'backend', + [LABEL]: 'critical', + [TEAM]: '4316', + [CUSTOM_FILTER]: 'priority = High', + [INCIDENT_PROJECT_KEY]: 'INC', + [INCIDENT_COMPONENT]: 'Payments', + [INCIDENT_LABEL]: 'sev-1', + [INCIDENT_TEAM]: 'team-ops', + [INCIDENT_ISSUE_TYPE]: 'ProductionIncident', + }); + + const filters = buildJqlFiltersFromEntity( + entity, + OPEN_ISSUES_FILTER_ANNOTATIONS, + ); + + expect(filters).toEqual({ + project: 'project = "TEST"', + component: 'component = "backend"', + label: 'labels = "critical"', + team: 'team = 4316', + customFilter: 'priority = High', + }); + }); + + it('incidents: should apply incident filters and ignore open-issues annotations', () => { + const entity = newEntityComponent({ + [INCIDENT_PROJECT_KEY]: 'INC', + [INCIDENT_COMPONENT]: 'Payments', + [INCIDENT_LABEL]: 'sev-1', + [INCIDENT_TEAM]: 'team-ops', + [COMPONENT]: 'Ignored', + [LABEL]: 'ignored-label', + [TEAM]: 'ignored-team', + [CUSTOM_FILTER]: 'ignored = true', + }); + + const filters = buildJqlFiltersFromEntity( + entity, + INCIDENT_FILTER_ANNOTATIONS, + { projectFallback: PROJECT_KEY }, + ); + + expect(filters).toEqual({ + project: 'project = "INC"', + component: 'component = "Payments"', + label: 'labels = "sev-1"', + team: 'team = team-ops', + }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/annotations/buildJqlFiltersFromEntity.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/annotations/buildJqlFiltersFromEntity.ts new file mode 100644 index 00000000000..044ddc26cfe --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/annotations/buildJqlFiltersFromEntity.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 type { Entity } from '@backstage/catalog-model'; +import { + sanitizeValue, + validateIdentifier, + validateJQLValue, +} from '../clients/utils'; +import type { JiraFilterAnnotations, JiraJqlFilters } from './types'; + +/** + * Reads entity annotations via the given filter-slot map and returns JQL + * clauses ready to AND together. + */ +export function buildJqlFiltersFromEntity( + entity: Entity, + filterAnnotations: JiraFilterAnnotations, + options?: { projectFallback?: string }, +): JiraJqlFilters { + const annotations = entity?.metadata?.annotations || {}; + const projectValue = + annotations[filterAnnotations.project] ?? + (options?.projectFallback + ? annotations[options.projectFallback] + : undefined); + + if (!projectValue) { + const requiredKeys = options?.projectFallback + ? `'${filterAnnotations.project}' or '${options.projectFallback}'` + : `'${filterAnnotations.project}'`; + throw new Error( + `Missing required ${requiredKeys} annotation for entity '${ + entity.metadata?.name || 'unknown' + }'`, + ); + } + + const projectAnnotationKey = annotations[filterAnnotations.project] + ? filterAnnotations.project + : options?.projectFallback ?? filterAnnotations.project; + + const filters: JiraJqlFilters = { + project: `project = "${validateJQLValue( + sanitizeValue(projectValue), + projectAnnotationKey, + )}"`, + }; + + if (filterAnnotations.component) { + const component = annotations[filterAnnotations.component]; + if (component) { + filters.component = `component = "${validateJQLValue( + sanitizeValue(component), + filterAnnotations.component, + )}"`; + } + } + + if (filterAnnotations.label) { + const label = annotations[filterAnnotations.label]; + if (label) { + filters.label = `labels = "${validateJQLValue( + sanitizeValue(label), + filterAnnotations.label, + )}"`; + } + } + + if (filterAnnotations.team) { + const team = annotations[filterAnnotations.team]; + if (team) { + filters.team = `team = ${validateIdentifier( + sanitizeValue(team), + filterAnnotations.team, + )}`; + } + } + + if (filterAnnotations.customFilter) { + const customFilter = annotations[filterAnnotations.customFilter]; + if (customFilter) { + filters.customFilter = customFilter; + } + } + + return filters; +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/annotations/index.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/annotations/index.ts new file mode 100644 index 00000000000..8d08e6f7846 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/annotations/index.ts @@ -0,0 +1,24 @@ +/* + * 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 { JiraFilterAnnotations, JiraJqlFilters } from './types'; +export { + ScorecardJiraAnnotations, + ScorecardJiraIncidentAnnotations, + OPEN_ISSUES_FILTER_ANNOTATIONS, + INCIDENT_FILTER_ANNOTATIONS, +} from './annotationKeys'; +export { buildJqlFiltersFromEntity } from './buildJqlFiltersFromEntity'; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/annotations/types.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/annotations/types.ts new file mode 100644 index 00000000000..ca360cf0717 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/annotations/types.ts @@ -0,0 +1,36 @@ +/* + * 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. + */ + +/** Maps filter slots to catalog annotation names (e.g. `jira/project-key`). */ +export interface JiraFilterAnnotations { + project: string; + component?: string; + label?: string; + team?: string; + customFilter?: string; +} + +/** + * Per-slot JQL clause strings produced from entity annotations + * (e.g. `project: project = "FOO"`). + */ +export interface JiraJqlFilters { + project: string; + component?: string; + label?: string; + team?: string; + customFilter?: string; +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/JiraClientFactory.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/JiraClientFactory.test.ts index c92940d9651..01c7204ef95 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/JiraClientFactory.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/JiraClientFactory.test.ts @@ -15,59 +15,159 @@ */ import type { Config } from '@backstage/config'; +import { mockServices } from '@backstage/backend-test-utils'; import { JiraDataCenterClientStrategy } from '../strategies/JiraDataCenterClientStrategy'; import { JiraClientFactory } from './JiraClientFactory'; import { JiraCloudClientStrategy } from '../strategies/JiraCloudClientStrategy'; import { newMockRootConfig } from '../../__fixtures__/testUtils'; import { - ConnectionStrategy, DirectConnectionStrategy, + ProxyConnectionStrategy, } from '../strategies/ConnectionStrategy'; jest.mock('../strategies/JiraDataCenterClientStrategy'); jest.mock('../strategies/JiraCloudClientStrategy'); +jest.mock('../strategies/ConnectionStrategy'); -const mockedConnectionStrategy = - DirectConnectionStrategy as unknown as jest.Mocked; +const mockedDirectConnectionStrategy = + DirectConnectionStrategy as unknown as jest.MockedClass< + typeof DirectConnectionStrategy + >; +const mockedProxyConnectionStrategy = + ProxyConnectionStrategy as unknown as jest.MockedClass< + typeof ProxyConnectionStrategy + >; describe('JiraClientFactory', () => { let config: Config; + const factoryOptions = { + auth: mockServices.auth(), + discovery: mockServices.discovery(), + logger: mockServices.logger.mock(), + }; afterEach(() => { jest.clearAllMocks(); }); - it('should create a JiraDataCenterClient when product is datacenter', () => { - config = newMockRootConfig({ jiraConfig: { product: 'datacenter' } }); + describe('fromConfig', () => { + it('should use proxy connection strategy when proxyPath exists', () => { + config = newMockRootConfig({ + jiraConfig: { product: 'cloud', proxyPath: '/jira/api' }, + }); - expect( - JiraClientFactory.create(config, mockedConnectionStrategy), - ).toBeInstanceOf(JiraDataCenterClientStrategy); - expect(JiraDataCenterClientStrategy).toHaveBeenCalledWith( - config, - mockedConnectionStrategy, - ); - }); + JiraClientFactory.fromConfig(config, factoryOptions); + expect(mockedProxyConnectionStrategy).toHaveBeenCalledWith( + '/jira/api', + factoryOptions.auth, + factoryOptions.discovery, + ); + expect(mockedDirectConnectionStrategy).not.toHaveBeenCalled(); + }); - it('should create a JiraCloudClient when product is cloud', () => { - config = newMockRootConfig({ jiraConfig: { product: 'cloud' } }); + it('should use direct connection strategy when proxyPath is not configured', () => { + config = newMockRootConfig({ + jiraConfig: { + baseUrl: 'https://example.atlassian.net', + token: 'token', + product: 'cloud', + proxyPath: undefined, + }, + }); - expect( - JiraClientFactory.create(config, mockedConnectionStrategy), - ).toBeInstanceOf(JiraCloudClientStrategy); - expect(JiraCloudClientStrategy).toHaveBeenCalledWith( - config, - mockedConnectionStrategy, - ); - }); + JiraClientFactory.fromConfig(config, factoryOptions); + expect(mockedDirectConnectionStrategy).toHaveBeenCalledWith( + 'https://example.atlassian.net', + 'token', + 'cloud', + ); + expect(mockedProxyConnectionStrategy).not.toHaveBeenCalled(); + }); + + it('should create datacenter client when product is datacenter with direct strategy', () => { + config = newMockRootConfig({ + jiraConfig: { product: 'datacenter', proxyPath: undefined }, + }); + + const client = JiraClientFactory.fromConfig(config, factoryOptions); + expect(client).toBeInstanceOf(JiraDataCenterClientStrategy); + expect(mockedDirectConnectionStrategy).toHaveBeenCalledWith( + 'https://example.com/api', + 'dummyToken', + 'datacenter', + ); + expect(JiraDataCenterClientStrategy).toHaveBeenCalledWith( + mockedDirectConnectionStrategy.mock.instances[0], + factoryOptions.logger, + ); + }); + + it('should create datacenter client when product is datacenter with proxy strategy', () => { + config = newMockRootConfig({ + jiraConfig: { product: 'datacenter', proxyPath: '/jira/api' }, + }); + + const client = JiraClientFactory.fromConfig(config, factoryOptions); + expect(client).toBeInstanceOf(JiraDataCenterClientStrategy); + expect(mockedProxyConnectionStrategy).toHaveBeenCalledWith( + '/jira/api', + factoryOptions.auth, + factoryOptions.discovery, + ); + expect(JiraDataCenterClientStrategy).toHaveBeenCalledWith( + mockedProxyConnectionStrategy.mock.instances[0], + factoryOptions.logger, + ); + }); + + it('should create cloud client when product is cloud with direct strategy', () => { + config = newMockRootConfig({ + jiraConfig: { product: 'cloud', proxyPath: undefined }, + }); + + const client = JiraClientFactory.fromConfig(config, factoryOptions); + + expect(client).toBeInstanceOf(JiraCloudClientStrategy); + expect(mockedDirectConnectionStrategy).toHaveBeenCalledWith( + 'https://example.com/api', + 'dummyToken', + 'cloud', + ); + expect(JiraCloudClientStrategy).toHaveBeenCalledWith( + mockedDirectConnectionStrategy.mock.instances[0], + factoryOptions.logger, + ); + }); + + it('should create cloud client when product is cloud with proxy strategy', () => { + config = newMockRootConfig({ + jiraConfig: { product: 'cloud', proxyPath: '/jira/api' }, + }); + + const client = JiraClientFactory.fromConfig(config, factoryOptions); + + expect(client).toBeInstanceOf(JiraCloudClientStrategy); + expect(mockedProxyConnectionStrategy).toHaveBeenCalledWith( + '/jira/api', + factoryOptions.auth, + factoryOptions.discovery, + ); + expect(JiraCloudClientStrategy).toHaveBeenCalledWith( + mockedProxyConnectionStrategy.mock.instances[0], + factoryOptions.logger, + ); + }); - it('should throw an error when product is invalid', () => { - config = newMockRootConfig({ jiraConfig: { product: 'foo' } }); + it('should throw when product is invalid', () => { + config = newMockRootConfig({ + jiraConfig: { product: 'foo', proxyPath: undefined }, + }); - expect(() => - JiraClientFactory.create(config, mockedConnectionStrategy), - ).toThrow( - "Invalid Jira product: foo. Valid products for 'jira.product' are: datacenter, cloud", - ); + expect(() => + JiraClientFactory.fromConfig(config, factoryOptions), + ).toThrow( + "Invalid Jira product: foo. Valid products for 'jira.product' are: datacenter, cloud", + ); + }); }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/JiraClientFactory.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/JiraClientFactory.ts index 76b5ae49075..c89db08df7a 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/JiraClientFactory.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/JiraClientFactory.ts @@ -15,25 +15,59 @@ */ import type { Config } from '@backstage/config'; +import type { + AuthService, + DiscoveryService, + LoggerService, +} from '@backstage/backend-plugin-api'; import { JIRA_CONFIG_PATH } from '../constants'; import { JiraClient } from '../clients/base'; import { JiraDataCenterClientStrategy } from '../strategies/JiraDataCenterClientStrategy'; import { JiraCloudClientStrategy } from '../strategies/JiraCloudClientStrategy'; -import { ConnectionStrategy } from '../strategies/ConnectionStrategy'; +import { + ConnectionStrategy, + DirectConnectionStrategy, + ProxyConnectionStrategy, +} from '../strategies/ConnectionStrategy'; +import { Product } from './types'; export class JiraClientFactory { - static create( + static fromConfig( config: Config, - connectionStrategy: ConnectionStrategy, + options: { + auth: AuthService; + discovery: DiscoveryService; + logger: LoggerService; + }, ): JiraClient { const jiraConfig = config.getConfig(JIRA_CONFIG_PATH); + const proxyPath = jiraConfig.getOptionalString('proxyPath'); + + let connectionStrategy: ConnectionStrategy; + if (proxyPath) { + connectionStrategy = new ProxyConnectionStrategy( + proxyPath, + options.auth, + options.discovery, + ); + } else { + connectionStrategy = new DirectConnectionStrategy( + jiraConfig.getString('baseUrl'), + jiraConfig.getString('token'), + jiraConfig.getString('product') as Product, + ); + } + const product = jiraConfig.getString('product'); switch (product) { case 'datacenter': - return new JiraDataCenterClientStrategy(config, connectionStrategy); + return new JiraDataCenterClientStrategy( + connectionStrategy, + options.logger, + ); case 'cloud': - return new JiraCloudClientStrategy(config, connectionStrategy); + return new JiraCloudClientStrategy(connectionStrategy, options.logger); default: throw new Error( `Invalid Jira product: ${product}. Valid products for 'jira.product' are: datacenter, cloud`, diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/base.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/base.test.ts index 59e76851c66..836f874fa9f 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/base.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/base.test.ts @@ -14,20 +14,15 @@ * limitations under the License. */ -import type { Config } from '@backstage/config'; +import type { ConnectionStrategy } from '../strategies/ConnectionStrategy'; +import { mockServices } from '@backstage/backend-test-utils'; import { JiraClient } from './base'; -import { ScorecardJiraAnnotations } from '../annotations'; -import { ConnectionStrategy } from '../strategies/ConnectionStrategy'; -import { - newEntityComponent, - newMockRootConfig, -} from '../../__fixtures__/testUtils'; - -const { PROJECT_KEY, COMPONENT, LABEL, TEAM, CUSTOM_FILTER } = - ScorecardJiraAnnotations; +import type { JiraIssue, Method } from './types'; +import { JsonObject } from '@backstage/types'; +import z from 'zod'; class TestJiraClient extends JiraClient { - getSearchEndpoint(): string { + getSearchCountEndpoint(): string { return '/search'; } @@ -42,30 +37,38 @@ class TestJiraClient extends JiraClient { getApiVersion(): number { return 3; } + + public getIssues(_jql: string): Promise { + throw new Error('Method not implemented.'); + } + + public sendPaginatedRequest(_options: { + url: string; + method: Method; + body?: JsonObject; + responseSchema: z.ZodType; + mapper: (page: TPage) => TOut[]; + fetchItemsLimit?: number; + }): Promise { + throw new Error('Method not implemented.'); + } } globalThis.fetch = jest.fn(); describe('JiraClient', () => { let testJiraClient: TestJiraClient; - let mockRootConfig: Config; let mockConnectionStrategy: ConnectionStrategy; + const mockedLogger = mockServices.logger.mock(); const mockMethod = 'GET'; const mockURL = 'https://example.com/api'; const mockResponse = { data: { total: 10 } }; beforeEach(() => { - const options = { - mandatoryFilter: 'type = Task AND resolution = Resolved', - customFilter: 'assignee = testerUser', - }; - - mockRootConfig = newMockRootConfig({ options }); - - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + (globalThis.fetch as jest.Mock).mockResolvedValue({ ok: true, - json: jest.fn().mockResolvedValueOnce({ total: 10 }), + json: jest.fn().mockResolvedValue({ total: 10 }), }); mockConnectionStrategy = { @@ -74,10 +77,10 @@ describe('JiraClient', () => { .mockReturnValue('https://example.com/api/rest/api/3'), getAuthHeaders: jest .fn() - .mockResolvedValue({ Authorization: 'Basic Fds31dsF32' }), + .mockResolvedValue({ Authorization: 'Basic dummyToken' }), }; - testJiraClient = new TestJiraClient(mockRootConfig, mockConnectionStrategy); + testJiraClient = new TestJiraClient(mockConnectionStrategy, mockedLogger); }); afterEach(() => { @@ -85,19 +88,12 @@ describe('JiraClient', () => { }); describe('constructor', () => { - it('should create api version', () => { + it('should have api version', () => { expect((testJiraClient as any).getApiVersion()).toEqual(3); }); - it('should create correct options', () => { - expect((testJiraClient as any).options).toEqual({ - mandatoryFilter: 'type = Task AND resolution = Resolved', - customFilter: 'assignee = testerUser', - }); - }); - - it('should create connection strategy', () => { - const client = new TestJiraClient(mockRootConfig, mockConnectionStrategy); + it('should have connection strategy', () => { + const client = new TestJiraClient(mockConnectionStrategy, mockedLogger); expect((client as any).connectionStrategy).toBe(mockConnectionStrategy); }); @@ -195,162 +191,6 @@ describe('JiraClient', () => { }); }); - describe('getFiltersFromEntity', () => { - it('should extract project filter correctly when entity has only "project key"', () => { - const entity = newEntityComponent({ [PROJECT_KEY]: 'TEST' }); - const filters = (testJiraClient as any).getFiltersFromEntity(entity); - - expect(filters).toEqual({ - project: 'project = "TEST"', - }); - }); - - it('should throw error for missing project key when entity is missing "project key"', () => { - const entity = newEntityComponent({}); - - expect(() => - (testJiraClient as any).getFiltersFromEntity(entity), - ).toThrow( - "Missing required 'jira/project-key' annotation for entity 'mock-entity'", - ); - }); - - it('should throw error for invalid "project key" when "project key" is invalid', () => { - const entity = newEntityComponent({ [PROJECT_KEY]: 'TEST$123' }); - - expect(() => - (testJiraClient as any).getFiltersFromEntity(entity), - ).toThrow( - 'jira/project-key contains invalid characters. Only alphanumeric, hyphens, spaces, and underscores are allowed.', - ); - }); - - it('should extract all filters correctly when entity has all expected annotations', () => { - const entity = newEntityComponent({ - [PROJECT_KEY]: 'TEST', - [COMPONENT]: 'backend', - [LABEL]: 'critical', - [TEAM]: '4316', - [CUSTOM_FILTER]: 'priority = High', - }); - - const filters = (testJiraClient as any).getFiltersFromEntity(entity); - - expect(filters).toEqual({ - project: 'project = "TEST"', - component: 'component = "backend"', - label: 'labels = "critical"', - team: 'team = 4316', - customFilter: 'priority = High', - }); - }); - - it('should throw error for invalid "component" when "component" is invalid', () => { - const entity = newEntityComponent({ - [PROJECT_KEY]: 'TEST', - [COMPONENT]: 'backend$123', - }); - - expect(() => - (testJiraClient as any).getFiltersFromEntity(entity), - ).toThrow( - 'jira/component contains invalid characters. Only alphanumeric, hyphens, spaces, and underscores are allowed.', - ); - }); - - it('should throw error for invalid "label" when "label" is invalid', () => { - const entity = newEntityComponent({ - [PROJECT_KEY]: 'TEST', - [LABEL]: 'critical$123', - }); - - expect(() => - (testJiraClient as any).getFiltersFromEntity(entity), - ).toThrow( - 'jira/label contains invalid characters. Only alphanumeric, hyphens, spaces, and underscores are allowed.', - ); - }); - - it('should throw error for invalid "team" when "team" is invalid', () => { - const entity = newEntityComponent({ - [PROJECT_KEY]: 'TEST', - [TEAM]: 'team-alpha$123', - }); - - expect(() => - (testJiraClient as any).getFiltersFromEntity(entity), - ).toThrow( - 'jira/team contains invalid characters. Only alphanumeric, hyphens, and underscores are allowed.', - ); - }); - }); - - describe('buildJqlFilters', () => { - it('should use provided mandatory filter when mandatory filter is provided in options', () => { - const filters = { project: 'project = "MOON"' }; - const jql = (testJiraClient as any).buildJqlFilters(filters); - const jqlFilters = jql.split(' AND '); - - expect(jqlFilters).toHaveLength(4); - expect(jqlFilters).toContain('(project = "MOON")'); - }); - - it('should use default mandatory filter when mandatory filter is not provided in options', () => { - const config = newMockRootConfig({ - options: { - mandatoryFilter: 'team = 4316', - }, - }); - - testJiraClient = new TestJiraClient(config, mockConnectionStrategy); - - const jql = (testJiraClient as any).buildJqlFilters({}); - expect(jql).toBe('(team = 4316)'); - }); - - it('should use provided annotation custom filter when custom filter is provided in annotation and options', () => { - const jql = (testJiraClient as any).buildJqlFilters({ - customFilter: 'assignee = Automobile', - }); - const jqlFilters = jql.split(' AND '); - - expect(jqlFilters).toHaveLength(3); - expect(jqlFilters).toContain('(assignee = Automobile)'); - }); - - it('should use provided annotation custom filter when custom filter is provided in annotation and not in options', () => { - const config = newMockRootConfig({ - options: { - mandatoryFilter: 'resolution = Unresolved', - }, - }); - testJiraClient = new TestJiraClient(config, mockConnectionStrategy); - - const jql = (testJiraClient as any).buildJqlFilters({ - customFilter: 'assignee = Robot', - }); - expect(jql).toBe('(assignee = Robot) AND (resolution = Unresolved)'); - }); - - it('should use provided options custom filter when custom filter is provided in options and not in annotation', () => { - const jql = (testJiraClient as any).buildJqlFilters({}); - const jqlFilters = jql.split(' AND '); - - expect(jqlFilters).toHaveLength(3); - expect(jqlFilters).toContain('(assignee = testerUser)'); - }); - - it('should not use any custom filters when custom filter is not provided in annotation and options', () => { - const config = newMockRootConfig(); - - const client = new TestJiraClient(config, mockConnectionStrategy); - - const jql = (client as any).buildJqlFilters({}); - - expect(jql).toContain('(type = Bug AND resolution = Unresolved)'); - }); - }); - describe('getBaseUrl', () => { it('should return URL', async () => { const baseUrl = await (testJiraClient as any).getBaseUrl(); @@ -366,18 +206,36 @@ describe('JiraClient', () => { describe('getAuthHeaders', () => { it('should return auth header', async () => { const authHeaders = await (testJiraClient as any).getAuthHeaders(); - expect(authHeaders).toEqual({ Authorization: 'Basic Fds31dsF32' }); + expect(authHeaders).toEqual({ Authorization: 'Basic dummyToken' }); }); }); describe('getCountOpenIssues', () => { - const mockEntity = newEntityComponent({ [PROJECT_KEY]: 'TEST' }); - - it('should return count of open issues', async () => { - const count = await (testJiraClient as any).getCountOpenIssues( - mockEntity, + it('should request open issues count with jql and return extracted count', async () => { + jest + .spyOn(testJiraClient as any, 'extractIssueCountFromResponse') + .mockReturnValue(7); + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ count: 7 }), + }); + + const count = await testJiraClient.getCountOpenIssues('project = "TEST"'); + + expect(count).toEqual(7); + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://example.com/api/rest/api/3/search', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Basic dummyToken', + }), + body: JSON.stringify({ jql: 'project = "TEST"' }), + }), + ); + expect(testJiraClient.extractIssueCountFromResponse).toHaveBeenCalledWith( + { count: 7 }, ); - expect(count).toEqual(10); }); }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/base.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/base.ts index 0b11b155eec..dd96492bca3 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/base.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/base.ts @@ -14,36 +14,22 @@ * limitations under the License. */ -import type { Config } from '@backstage/config'; -import type { Entity } from '@backstage/catalog-model'; -import { JiraEntityFilters, JiraOptions, RequestOptions } from './types'; -import { JIRA_MANDATORY_FILTER, OPEN_ISSUES_CONFIG_PATH } from '../constants'; -import { ScorecardJiraAnnotations } from '../annotations'; -import { sanitizeValue, validateIdentifier, validateJQLValue } from './utils'; +import type { LoggerService } from '@backstage/backend-plugin-api'; +import type { JsonObject } from '@backstage/types'; +import type { z } from 'zod'; +import { JiraIssue, Method, RequestOptions } from './types'; import { ConnectionStrategy } from '../strategies/ConnectionStrategy'; -const { PROJECT_KEY, COMPONENT, LABEL, TEAM, CUSTOM_FILTER } = - ScorecardJiraAnnotations; - export abstract class JiraClient { - protected readonly options?: JiraOptions; protected readonly connectionStrategy: ConnectionStrategy; + protected readonly logger: LoggerService; - constructor(rootConfig: Config, connectionStrategy: ConnectionStrategy) { + constructor(connectionStrategy: ConnectionStrategy, logger: LoggerService) { this.connectionStrategy = connectionStrategy; - - const jiraOptions = rootConfig.getOptionalConfig( - `${OPEN_ISSUES_CONFIG_PATH}.options`, - ); - if (jiraOptions) { - this.options = { - mandatoryFilter: jiraOptions.getOptionalString('mandatoryFilter'), - customFilter: jiraOptions.getOptionalString('customFilter'), - }; - } + this.logger = logger; } - protected abstract getSearchEndpoint(): string; + protected abstract getSearchCountEndpoint(): string; protected abstract buildSearchBody(jql: string): string; @@ -51,6 +37,8 @@ export abstract class JiraClient { protected abstract getApiVersion(): number; + public abstract getIssues(jql: string): Promise; + protected async sendRequest({ url, method, @@ -83,76 +71,18 @@ export abstract class JiraClient { } } - protected getFiltersFromEntity(entity: Entity): JiraEntityFilters { - const annotations = entity?.metadata?.annotations || {}; - - const projectKey = annotations[PROJECT_KEY]; - if (!projectKey) { - throw new Error( - `Missing required '${PROJECT_KEY}' annotation for entity '${ - entity.metadata?.name || 'unknown' - }'`, - ); - } - - const sanitizedProjectKey = sanitizeValue(projectKey); - const filters: JiraEntityFilters = { - project: `project = "${validateJQLValue( - sanitizedProjectKey, - PROJECT_KEY, - )}"`, - }; - - const component = annotations[COMPONENT]; - if (component) { - const sanitizedComponent = sanitizeValue(component); - filters.component = `component = "${validateJQLValue( - sanitizedComponent, - COMPONENT, - )}"`; - } - - const label = annotations[LABEL]; - if (label) { - const sanitizedLabel = sanitizeValue(label); - filters.label = `labels = "${validateJQLValue(sanitizedLabel, LABEL)}"`; - } - - const team = annotations[TEAM]; - if (team) { - const sanitizedTeam = sanitizeValue(team); - filters.team = `team = ${validateIdentifier(sanitizedTeam, TEAM)}`; - } - - const customFilter = annotations[CUSTOM_FILTER]; - if (customFilter) { - filters.customFilter = customFilter; - } - - return filters; - } - - protected buildJqlFilters(filters: JiraEntityFilters): string { - const { customFilter: annotationCustomFilter } = filters; - const { mandatoryFilter, customFilter: optionsCustomFilter } = - this.options || {}; - - const defaultFilterQuery = mandatoryFilter ?? JIRA_MANDATORY_FILTER; - - const customFilterQuery = - !annotationCustomFilter && optionsCustomFilter - ? optionsCustomFilter - : null; - - return Object.values({ - ...filters, - defaultFilterQuery, - customFilterQuery, - }) - .filter(value => value && value !== '') - .map(value => `(${value})`) - .join(' AND '); - } + public abstract sendPaginatedRequest(options: { + url: string; + method: Method; + body?: JsonObject; + responseSchema: z.ZodType; + mapper: (page: TPage) => TOut[]; + /** + * Client-side cap on total mapped items across all pages. + * Defaults to 1000. + */ + fetchItemsLimit?: number; + }): Promise; protected async getBaseUrl(): Promise { const apiVersion = this.getApiVersion(); @@ -163,12 +93,9 @@ export abstract class JiraClient { return this.connectionStrategy.getAuthHeaders(); } - public async getCountOpenIssues(entity: Entity): Promise { + public async getCountOpenIssues(jql: string): Promise { const baseUrl = await this.getBaseUrl(); - const countOpenIssuesUrl = `${baseUrl}${this.getSearchEndpoint()}`; - - const filters = this.getFiltersFromEntity(entity); - const jql = this.buildJqlFilters(filters); + const countOpenIssuesUrl = `${baseUrl}${this.getSearchCountEndpoint()}`; const headers = await this.getAuthHeaders(); const data = await this.sendRequest({ 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 new file mode 100644 index 00000000000..f8b30bf9ea1 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/mappers.test.ts @@ -0,0 +1,52 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mapJiraIssues } from './mappers'; +import type { JiraSearchIssue } from './schemas/jiraSearchIssue'; + +describe('mapJiraIssues', () => { + const issues: JiraSearchIssue[] = [ + { + id: '10001', + fields: { + created: '2026-06-01T10:00:00.000+0530', + resolutiondate: '2026-06-01T12:00:00.000+0530', + }, + }, + { + id: '10002', + fields: { + created: '2026-06-02T10:00:00.000Z', + resolutiondate: null, + }, + }, + ]; + + it('should map Jira API search issues to domain issues', () => { + expect(mapJiraIssues(issues)).toEqual([ + { + id: '10001', + createdAt: '2026-06-01T04:30:00.000Z', + resolutionAt: '2026-06-01T06:30:00.000Z', + }, + { + id: '10002', + createdAt: '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 new file mode 100644 index 00000000000..370bf96ebe9 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/mappers.ts @@ -0,0 +1,29 @@ +/* + * 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 { JiraSearchIssue } from './schemas/jiraSearchIssue'; +import type { JiraIssue } from './types'; +import { toIsoDateTime } from './utils'; + +export function mapJiraIssues(issues: JiraSearchIssue[]): JiraIssue[] { + return issues.map(issue => ({ + id: issue.id, + createdAt: toIsoDateTime(issue.fields.created), + resolutionAt: issue.fields.resolutiondate + ? toIsoDateTime(issue.fields.resolutiondate) + : null, + })); +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/schemas/jiraSearchIssue.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/schemas/jiraSearchIssue.ts new file mode 100644 index 00000000000..5d50e6cec58 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/clients/schemas/jiraSearchIssue.ts @@ -0,0 +1,31 @@ +/* + * 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 { z } from 'zod'; + +export const jiraSearchIssueSchema = z + .object({ + id: z.string(), + fields: z + .object({ + created: z.string(), + resolutiondate: z.string().nullable().optional(), + }) + .passthrough(), + }) + .passthrough(); + +export type JiraSearchIssue = z.infer; 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 9e486818595..cd800722552 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 @@ -16,17 +16,10 @@ export type Product = 'datacenter' | 'cloud'; -export interface JiraOptions { - mandatoryFilter?: string; - customFilter?: string; -} - -export interface JiraEntityFilters { - project: string; - component?: string; - label?: string; - team?: string; - customFilter?: string; +export interface JiraIssue { + id: string; + createdAt: string; + resolutionAt: string | null; } export type Method = 'GET' | 'POST' | 'PUT' | 'DELETE'; 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 49a78548f23..0bfbd75b274 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 @@ -14,7 +14,14 @@ * limitations under the License. */ -import { validateJQLValue, validateIdentifier, sanitizeValue } from './utils'; +import { + joinJqlClauses, + sanitizeValue, + toIsoDateTime, + toJiraDateTime, + validateIdentifier, + validateJQLValue, +} from './utils'; describe('utils', () => { describe('validateJQLValue', () => { @@ -46,4 +53,54 @@ describe('utils', () => { expect(sanitizeValue('T"EST\\123')).toBe('T\\"EST\\\\123'); }); }); + + describe('joinJqlClauses', () => { + it('wraps clauses in parentheses and joins with AND', () => { + expect( + joinJqlClauses([ + 'project = "INC"', + 'type = Incident', + 'created >= "2026-06-01 00:00"', + ]), + ).toBe( + '(project = "INC") AND (type = Incident) AND (created >= "2026-06-01 00:00")', + ); + }); + + it('skips undefined, null, and empty clauses', () => { + expect( + joinJqlClauses([ + 'project = "INC"', + undefined, + null, + '', + 'type = Incident', + ]), + ).toBe('(project = "INC") AND (type = Incident)'); + }); + + it('returns an empty string when no clauses remain', () => { + expect(joinJqlClauses([undefined, null, ''])).toBe(''); + }); + + it('wraps a single clause', () => { + expect(joinJqlClauses(['project = "INC"'])).toBe('(project = "INC")'); + }); + }); + + 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('toIsoDateTime', () => { + it('should normalize Jira datetime offset without colon', () => { + expect(toIsoDateTime('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 5c0d33643d3..187bedcc8be 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 @@ -35,3 +35,42 @@ export function validateIdentifier(value: string, fieldName: string): string { export function sanitizeValue(value: string): string { return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); } + +export function joinJqlClauses( + clauses: Array, +): string { + return clauses + .filter((value): value is string => Boolean(value && value !== '')) + .map(value => `(${value})`) + .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(); +} + +function parseDateTime(value: string): Date { + const normalizedValue = normalizeTimezone(value); + const parsedDate = new Date(normalizedValue); + if (Number.isNaN(parsedDate.getTime())) { + throw new Error(`Invalid datetime "${value}"`); + } + return parsedDate; +} + +function normalizeTimezone(value: string): string { + // Jira can return offsets like +0530; normalize to +05:30 for strict ISO parsing. + 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 new file mode 100644 index 00000000000..503ad4b7391 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/collectors/JiraIncidentsCollector.test.ts @@ -0,0 +1,168 @@ +/* + * 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 { newEntityComponent } from '../../__fixtures__/testUtils'; +import { + ScorecardJiraAnnotations, + ScorecardJiraIncidentAnnotations, +} from '../annotations'; +import { JiraClient } from '../clients/base'; +import { JiraIncidentsCollector } from './JiraIncidentsCollector'; + +const { PROJECT_KEY } = ScorecardJiraAnnotations; +const { + INCIDENT_PROJECT_KEY, + INCIDENT_COMPONENT, + INCIDENT_LABEL, + INCIDENT_ISSUE_TYPE, +} = ScorecardJiraIncidentAnnotations; + +describe('JiraIncidentsCollector', () => { + const mockJiraClient = { + getIssues: jest.fn(), + } as unknown as jest.Mocked; + + let collector: JiraIncidentsCollector; + + const input = { + from: '2026-06-01T00:00:00.000Z', + to: '2026-06-30T23:59:59.999Z', + }; + + const mockEntity = newEntityComponent({ + [INCIDENT_PROJECT_KEY]: 'INC', + }); + + const defaultIncidents = [ + { + id: 'INC-100', + createdAt: '2026-06-01T10:00:00.000Z', + resolutionAt: '2026-06-01T12:00:00.000Z', + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + mockJiraClient.getIssues.mockResolvedValue(defaultIncidents); + collector = new JiraIncidentsCollector(mockJiraClient); + }); + + describe('collect', () => { + it('should return incidents when Jira client processed successfully', async () => { + const result = await collector.collect({ entity: mockEntity, input }); + + expect(result).toEqual({ incidents: defaultIncidents }); + }); + + it('should propagate errors from Jira client', async () => { + mockJiraClient.getIssues.mockRejectedValue(new Error('Jira API error')); + + await expect( + collector.collect({ entity: mockEntity, input }), + ).rejects.toThrow('Jira API error'); + }); + + it('should use default issue type when input.issueType is unset', async () => { + 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")', + ); + }); + + it('should overwrite default issue type with input.issueType', async () => { + await collector.collect({ + entity: mockEntity, + input: { ...input, issueType: 'ServiceIncident' }, + }); + + expect(mockJiraClient.getIssues).toHaveBeenCalledWith( + '(project = "INC") AND (type = "ServiceIncident") AND (created >= "2026-06-01 00:00") AND (created <= "2026-06-30 23:59")', + ); + }); + + it('should include entity annotation filters with default issue type', async () => { + await collector.collect({ + entity: newEntityComponent({ + [INCIDENT_PROJECT_KEY]: 'INC', + [INCIDENT_COMPONENT]: 'Payments', + [INCIDENT_LABEL]: 'sev-1', + }), + input, + }); + + 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")', + ); + }); + + it('should apply input.issueType with entity annotation filters', async () => { + await collector.collect({ + entity: newEntityComponent({ + [INCIDENT_PROJECT_KEY]: 'INC', + [INCIDENT_COMPONENT]: 'Payments', + }), + input: { ...input, issueType: 'ServiceIncident' }, + }); + + 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")', + ); + }); + + it('should prefer entity issue-type annotation over input.issueType', async () => { + await collector.collect({ + entity: newEntityComponent({ + [INCIDENT_PROJECT_KEY]: 'INC', + [INCIDENT_ISSUE_TYPE]: 'ProductionIncident', + }), + input: { ...input, issueType: 'ServiceIncident' }, + }); + + expect(mockJiraClient.getIssues).toHaveBeenCalledWith( + expect.stringContaining('(type = "ProductionIncident")'), + ); + expect(mockJiraClient.getIssues).toHaveBeenCalledWith( + expect.not.stringContaining('(type = "ServiceIncident")'), + ); + }); + + it('should use entity issue-type annotation when input.issueType is unset', async () => { + await collector.collect({ + entity: newEntityComponent({ + [INCIDENT_PROJECT_KEY]: 'INC', + [INCIDENT_ISSUE_TYPE]: 'ProductionIncident', + }), + input, + }); + + expect(mockJiraClient.getIssues).toHaveBeenCalledWith( + expect.stringContaining('(type = "ProductionIncident")'), + ); + }); + + it('should fall back to project-key when incident project key is missing', async () => { + await collector.collect({ + entity: newEntityComponent({ [PROJECT_KEY]: 'PROJ' }), + input, + }); + + expect(mockJiraClient.getIssues).toHaveBeenCalledWith( + expect.stringContaining('(project = "PROJ")'), + ); + }); + }); +}); 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 new file mode 100644 index 00000000000..86bbcea7d91 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/collectors/JiraIncidentsCollector.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 { Entity } from '@backstage/catalog-model'; +import type { Collector } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { z } from 'zod'; +import { + buildJqlFiltersFromEntity, + INCIDENT_FILTER_ANNOTATIONS, + ScorecardJiraAnnotations, +} from '../annotations'; +import { JiraClient } from '../clients/base'; +import { buildIncidentJql } from './incidentJql'; +import { + incidentsCollectorInputSchema, + incidentsCollectorOutputSchema, +} from './schemas/incidentsSchemas'; + +const { PROJECT_KEY } = ScorecardJiraAnnotations; + +export class JiraIncidentsCollector + implements + Collector< + (typeof JiraIncidentsCollector)['inputSchema'], + (typeof JiraIncidentsCollector)['outputSchema'] + > +{ + static readonly inputSchema = incidentsCollectorInputSchema; + static readonly outputSchema = incidentsCollectorOutputSchema; + + private readonly jiraClient: JiraClient; + + constructor(jiraClient: JiraClient) { + this.jiraClient = jiraClient; + } + + getCollectorId(): string { + return 'jira:incidents'; + } + + getCollectorDescription(): string { + return 'Collects Jira incidents.'; + } + + getInputSchema() { + return JiraIncidentsCollector.inputSchema; + } + + getOutputSchema() { + return JiraIncidentsCollector.outputSchema; + } + + async collect(options: { + entity: Entity; + input: z.infer<(typeof JiraIncidentsCollector)['inputSchema']>; + }): Promise> { + const entityFilters = buildJqlFiltersFromEntity( + options.entity, + INCIDENT_FILTER_ANNOTATIONS, + { projectFallback: PROJECT_KEY }, + ); + const jql = buildIncidentJql( + entityFilters, + { + from: options.input.from, + to: options.input.to, + issueType: options.input.issueType, + }, + options.entity, + ); + const incidents = await this.jiraClient.getIssues(jql); + + return { + incidents, + }; + } +} 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 new file mode 100644 index 00000000000..5d897a9f551 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/collectors/incidentJql.test.ts @@ -0,0 +1,97 @@ +/* + * 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 { ScorecardJiraIncidentAnnotations } from '../annotations'; +import { newEntityComponent } from '../../__fixtures__/testUtils'; +import { buildIncidentJql } from './incidentJql'; + +const { INCIDENT_ISSUE_TYPE } = ScorecardJiraIncidentAnnotations; + +describe('buildIncidentJql', () => { + const options = { + from: '2026-06-01T00:00:00.000Z', + to: '2026-06-30T23:59:59.999Z', + }; + + const baseFilters = { + project: 'project = "INC"', + }; + + it('should use default issue type and date bounds when input.issueType is unset', () => { + 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")', + ); + }); + + it('should apply input.issueType instead of default issue type', () => { + const jql = buildIncidentJql( + baseFilters, + { ...options, issueType: 'ServiceIncident' }, + newEntityComponent(), + ); + + expect(jql).toBe( + '(project = "INC") AND (type = "ServiceIncident") AND (created >= "2026-06-01 00:00") AND (created <= "2026-06-30 23:59")', + ); + }); + + it('should apply entity filters with default issue type and date bounds', () => { + const jql = buildIncidentJql( + { + project: 'project = "INC"', + component: 'component = "Payments"', + label: 'labels = "sev-1"', + }, + options, + newEntityComponent(), + ); + + 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")', + ); + }); + + it('should prefer entity issue-type annotation over input.issueType', () => { + const jql = buildIncidentJql( + baseFilters, + { ...options, issueType: 'ServiceIncident' }, + newEntityComponent({ + [INCIDENT_ISSUE_TYPE]: 'ProductionIncident', + }), + ); + + expect(jql).toBe( + '(project = "INC") AND (type = "ProductionIncident") AND (created >= "2026-06-01 00:00") AND (created <= "2026-06-30 23:59")', + ); + expect(jql).not.toContain('(type = "ServiceIncident")'); + }); + + it('should use entity issue-type annotation when input.issueType is unset', () => { + const jql = buildIncidentJql( + baseFilters, + options, + newEntityComponent({ + [INCIDENT_ISSUE_TYPE]: 'ProductionIncident', + }), + ); + + expect(jql).toBe( + '(project = "INC") AND (type = "ProductionIncident") AND (created >= "2026-06-01 00:00") AND (created <= "2026-06-30 23:59")', + ); + }); +}); 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 new file mode 100644 index 00000000000..b95eaf0fa67 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/collectors/incidentJql.ts @@ -0,0 +1,65 @@ +/* + * 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 { Entity } from '@backstage/catalog-model'; +import { + ScorecardJiraIncidentAnnotations, + type JiraJqlFilters, +} from '../annotations'; +import { + joinJqlClauses, + sanitizeValue, + toJiraDateTime, + validateJQLValue, +} from '../clients/utils'; +import { DEFAULT_INCIDENT_ISSUE_TYPE } from '../constants'; + +const { INCIDENT_ISSUE_TYPE } = ScorecardJiraIncidentAnnotations; + +export function buildIncidentJql( + filters: JiraJqlFilters, + options: { + from: string; + to: string; + issueType?: string; + }, + entity: Entity, +): string { + const from = toJiraDateTime(options.from); + const to = toJiraDateTime(options.to); + const issueType = resolveIncidentIssueType(entity, options.issueType); + + return joinJqlClauses([ + ...Object.values(filters), + `type = "${issueType}"`, + `created >= "${from}"`, + `created <= "${to}"`, + ]); +} + +function resolveIncidentIssueType( + entity: Entity, + inputIssueType?: string, +): string { + const annotations = entity.metadata?.annotations || {}; + // Entity annotation overrides configured input default. + const issueType = + annotations[INCIDENT_ISSUE_TYPE] || + inputIssueType || + DEFAULT_INCIDENT_ISSUE_TYPE; + + return validateJQLValue(sanitizeValue(issueType), 'type'); +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/collectors/schemas/incidentsSchemas.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/collectors/schemas/incidentsSchemas.ts new file mode 100644 index 00000000000..5aad56ea275 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/collectors/schemas/incidentsSchemas.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 { z } from 'zod'; + +export const incidentsCollectorInputSchema = z + .object({ + from: z.string().datetime(), + to: z.string().datetime(), + /** + * Jira issue type for incident queries. + * Defaults to `Incident`. Overridden by entity annotation + * `jira/incident-issue-type` when set. + */ + issueType: z.string().min(1).optional(), + }) + .passthrough(); + +const incidentSchema = z.object({ + id: z.string(), + createdAt: z.string().datetime(), + resolutionAt: z.string().datetime().nullable(), +}); + +export const incidentsCollectorOutputSchema = z.object({ + incidents: z.array(incidentSchema), +}); + +export type Incident = z.infer; 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 05a8dea6ce7..358641b099c 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 @@ -15,3 +15,5 @@ */ export * from './jiraOpenIssues'; +export * from './jiraIncidents'; +export * from './pagination'; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/constants/jiraIncidents.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/constants/jiraIncidents.ts new file mode 100644 index 00000000000..0dfb7be1756 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/constants/jiraIncidents.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 const DEFAULT_INCIDENT_ISSUE_TYPE = 'Incident' as const; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/constants/pagination.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/constants/pagination.ts new file mode 100644 index 00000000000..c6537a6d487 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/constants/pagination.ts @@ -0,0 +1,18 @@ +/* + * 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. + */ + +/** Client-side cap on total items across paginated Jira fetches (not per-page maxResults). */ +export const DEFAULT_PAGINATED_FETCH_ITEMS_LIMIT = 1000; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/metricProviders/JiraOpenIssuesConfig.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/metricProviders/JiraOpenIssuesConfig.test.ts new file mode 100644 index 00000000000..3d2fc908ca3 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/metricProviders/JiraOpenIssuesConfig.test.ts @@ -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. + */ + +import { newMockRootConfig } from '../../__fixtures__/testUtils'; +import { parseJiraOpenIssuesConfigOptions } from './JiraOpenIssuesConfig'; + +describe('parseJiraOpenIssuesConfigOptions', () => { + it('should return empty options when options are not configured', () => { + const config = newMockRootConfig(); + + expect(parseJiraOpenIssuesConfigOptions(config)).toEqual({ + mandatoryFilter: undefined, + customFilter: undefined, + }); + }); + + it('should parse mandatoryFilter and customFilter from options', () => { + const config = newMockRootConfig({ + options: { + mandatoryFilter: 'type = Task', + customFilter: 'priority = High', + }, + }); + + expect(parseJiraOpenIssuesConfigOptions(config)).toEqual({ + mandatoryFilter: 'type = Task', + customFilter: 'priority = High', + }); + }); + + it('should parse partial options', () => { + const config = newMockRootConfig({ + options: { + mandatoryFilter: 'resolution = Unresolved', + }, + }); + + expect(parseJiraOpenIssuesConfigOptions(config)).toEqual({ + mandatoryFilter: 'resolution = Unresolved', + customFilter: undefined, + }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/metricProviders/JiraOpenIssuesConfig.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/metricProviders/JiraOpenIssuesConfig.ts new file mode 100644 index 00000000000..71abb8a9224 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/metricProviders/JiraOpenIssuesConfig.ts @@ -0,0 +1,39 @@ +/* + * 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 { Config } from '@backstage/config'; +import { OPEN_ISSUES_CONFIG_PATH } from '../constants'; + +export interface JiraOpenIssuesOptions { + mandatoryFilter?: string; + customFilter?: string; +} + +/** + * Parses open-issues provider options from app-config. + */ +export function parseJiraOpenIssuesConfigOptions( + config: Config, +): JiraOpenIssuesOptions { + const optionsConfig = config.getOptionalConfig( + `${OPEN_ISSUES_CONFIG_PATH}.options`, + ); + + return { + mandatoryFilter: optionsConfig?.getOptionalString('mandatoryFilter'), + customFilter: optionsConfig?.getOptionalString('customFilter'), + }; +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/metricProviders/JiraOpenIssuesProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/metricProviders/JiraOpenIssuesProvider.test.ts index f79f85d6ff0..b936095213e 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/metricProviders/JiraOpenIssuesProvider.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/metricProviders/JiraOpenIssuesProvider.test.ts @@ -14,88 +14,48 @@ * limitations under the License. */ -import type { Config } from '@backstage/config'; -import type { Entity } from '@backstage/catalog-model'; import { DEFAULT_NUMBER_THRESHOLDS } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; -import { JiraOpenIssuesProvider } from './JiraOpenIssuesProvider'; -import { JiraClientFactory } from '../clients/JiraClientFactory'; -import { JiraClient } from '../clients/base'; -import { mockServices } from '@backstage/backend-test-utils'; import { newEntityComponent, newMockRootConfig, } from '../../__fixtures__/testUtils'; import { ScorecardJiraAnnotations } from '../annotations'; -import { - DirectConnectionStrategy, - ProxyConnectionStrategy, -} from '../strategies/ConnectionStrategy'; - -const { PROJECT_KEY } = ScorecardJiraAnnotations; - -jest.mock('../clients/JiraClientFactory'); -jest.mock('../strategies/ConnectionStrategy'); - -const mockJiraClient = { - getCountOpenIssues: jest.fn(), -} as unknown as jest.Mocked; - -const mockedJiraClientFactory = JiraClientFactory as jest.Mocked< - typeof JiraClientFactory ->; -const mockedProxyConnectionStrategy = - ProxyConnectionStrategy as unknown as jest.Mocked< - typeof ProxyConnectionStrategy - >; -const mockedDirectConnectionStrategy = - DirectConnectionStrategy as unknown as jest.Mocked< - typeof DirectConnectionStrategy - >; - -const mockEntity: Entity = newEntityComponent({ - [PROJECT_KEY]: 'TEST', -}); +import { JiraClient } from '../clients/base'; +import { JiraOpenIssuesProvider } from './JiraOpenIssuesProvider'; -const mockAuthOptions = { - discovery: mockServices.discovery(), - auth: mockServices.auth(), -}; +const { PROJECT_KEY, COMPONENT, LABEL, TEAM, CUSTOM_FILTER } = + ScorecardJiraAnnotations; describe('JiraOpenIssuesProvider', () => { - let mockConfig: Config; + const mockJiraClient = { + getCountOpenIssues: jest.fn(), + } as unknown as jest.Mocked; + + let provider: JiraOpenIssuesProvider; + + const mockEntity = newEntityComponent({ [PROJECT_KEY]: 'TEST' }); beforeEach(() => { jest.clearAllMocks(); - mockedJiraClientFactory.create.mockReturnValue(mockJiraClient); - mockConfig = newMockRootConfig(); + provider = JiraOpenIssuesProvider.fromConfig(newMockRootConfig(), { + jiraClient: mockJiraClient, + }); }); describe('getProviderDatasourceId', () => { it('should return "jira"', () => { - const provider = JiraOpenIssuesProvider.fromConfig( - mockConfig, - mockAuthOptions, - ); expect(provider.getProviderDatasourceId()).toEqual('jira'); }); }); describe('getProviderId', () => { it('should return "jira.openIssues"', () => { - const provider = JiraOpenIssuesProvider.fromConfig( - mockConfig, - mockAuthOptions, - ); expect(provider.getProviderId()).toEqual('jira.openIssues'); }); }); describe('getMetrics', () => { it('should return correct metric metadata with threshold', () => { - const provider = JiraOpenIssuesProvider.fromConfig( - mockConfig, - mockAuthOptions, - ); const metrics = provider.getMetrics(); expect(metrics).toHaveLength(1); @@ -112,78 +72,136 @@ describe('JiraOpenIssuesProvider', () => { }); describe('fromConfig', () => { - it('should create provider with default thresholds on metric', () => { - const provider = JiraOpenIssuesProvider.fromConfig( - mockConfig, - mockAuthOptions, + it('should load options from app-config', () => { + provider = JiraOpenIssuesProvider.fromConfig( + newMockRootConfig({ + options: { + mandatoryFilter: 'type = Task', + customFilter: 'priority = High', + }, + }), + { jiraClient: mockJiraClient }, ); - expect(provider.getMetrics()[0].thresholds).toEqual( - DEFAULT_NUMBER_THRESHOLDS, - ); + expect((provider as any).options).toEqual({ + mandatoryFilter: 'type = Task', + customFilter: 'priority = High', + }); }); - it('should create provider with proxy connection strategy when proxy path is configured', () => { - JiraOpenIssuesProvider.fromConfig(mockConfig, mockAuthOptions); - expect(mockedProxyConnectionStrategy).toHaveBeenCalledWith( - '/jira/api', - mockAuthOptions.auth, - mockAuthOptions.discovery, - ); - expect(mockedJiraClientFactory.create).toHaveBeenCalledWith( - mockConfig, - expect.any(ProxyConnectionStrategy), - ); - }); + it('should leave empty options if not set in app-config', () => { + provider = JiraOpenIssuesProvider.fromConfig(newMockRootConfig({}), { + jiraClient: mockJiraClient, + }); - it('should create provider with direct connection strategy when proxy path is not configured', () => { - const config = newMockRootConfig({ - jiraConfig: { proxyPath: undefined }, + expect((provider as any).options).toEqual({ + mandatoryFilter: undefined, + customFilter: undefined, }); - JiraOpenIssuesProvider.fromConfig(config, mockAuthOptions); - expect(mockedDirectConnectionStrategy).toHaveBeenCalledWith( - 'https://example.com/api', - 'Fds31dsF32', - 'cloud', - ); }); }); describe('calculateMetrics', () => { - it('should return the count of open issues when Jira client processed successfully', async () => { + beforeEach(() => { mockJiraClient.getCountOpenIssues.mockResolvedValue(5); + }); + + it('should return the count of open issues when Jira client processed successfully', async () => { + const results = await provider.calculateMetrics(mockEntity); + + expect(results.get('jira.openIssues')).toBe(5); + }); + + it('should propagate errors from Jira client', async () => { + mockJiraClient.getCountOpenIssues.mockRejectedValue( + new Error('Jira API error'), + ); - const provider = JiraOpenIssuesProvider.fromConfig( - mockConfig, - mockAuthOptions, + await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( + 'Jira API error', ); + }); + + it('should use default mandatory filter when app-config options are unset', async () => { const results = await provider.calculateMetrics(mockEntity); expect(results.get('jira.openIssues')).toBe(5); expect(mockJiraClient.getCountOpenIssues).toHaveBeenCalledWith( - mockEntity, + '(project = "TEST") AND (type = Bug AND resolution = Unresolved)', ); }); - describe('when Jira client processed with error', () => { - beforeEach(() => { - mockJiraClient.getCountOpenIssues.mockRejectedValue( - new Error('Jira API error'), - ); - }); + it('should overwrite default mandatory filter with app-config mandatoryFilter', async () => { + provider = JiraOpenIssuesProvider.fromConfig( + newMockRootConfig({ + options: { + mandatoryFilter: 'type = Task AND resolution = Resolved', + }, + }), + { jiraClient: mockJiraClient }, + ); + + await provider.calculateMetrics(mockEntity); + + expect(mockJiraClient.getCountOpenIssues).toHaveBeenCalledWith( + '(project = "TEST") AND (type = Task AND resolution = Resolved)', + ); + }); + + it('should apply app-config mandatoryFilter and customFilter', async () => { + provider = JiraOpenIssuesProvider.fromConfig( + newMockRootConfig({ + options: { + mandatoryFilter: 'type = Task AND resolution = Resolved', + customFilter: 'assignee = testerUser', + }, + }), + { jiraClient: mockJiraClient }, + ); + + await provider.calculateMetrics(mockEntity); + + expect(mockJiraClient.getCountOpenIssues).toHaveBeenCalledWith( + '(project = "TEST") AND (type = Task AND resolution = Resolved) AND (assignee = testerUser)', + ); + }); - it('should propagate errors from Jira client', async () => { - const provider = JiraOpenIssuesProvider.fromConfig( - mockConfig, - mockAuthOptions, - ); - await expect(provider.calculateMetrics(mockEntity)).rejects.toThrow( - 'Jira API error', - ); - expect(mockJiraClient.getCountOpenIssues).toHaveBeenCalledWith( - mockEntity, - ); + it('should include entity annotation filters without custom-filter with default mandatory filter', async () => { + const entity = newEntityComponent({ + [PROJECT_KEY]: 'TEST', + [COMPONENT]: 'backend', + [LABEL]: 'critical', + [TEAM]: '4316', }); + + await provider.calculateMetrics(entity); + + expect(mockJiraClient.getCountOpenIssues).toHaveBeenCalledWith( + '(project = "TEST") AND (component = "backend") AND (labels = "critical") AND (team = 4316) AND (type = Bug AND resolution = Unresolved)', + ); + }); + + it('should prefer entity custom-filter annotation over app-config customFilter', async () => { + provider = JiraOpenIssuesProvider.fromConfig( + newMockRootConfig({ + options: { + mandatoryFilter: 'resolution = Unresolved', + customFilter: 'assignee = fromConfig', + }, + }), + { jiraClient: mockJiraClient }, + ); + + await provider.calculateMetrics( + newEntityComponent({ + [PROJECT_KEY]: 'TEST', + [CUSTOM_FILTER]: 'assignee = fromAnnotation', + }), + ); + + expect(mockJiraClient.getCountOpenIssues).toHaveBeenCalledWith( + '(project = "TEST") AND (assignee = fromAnnotation) AND (resolution = Unresolved)', + ); }); }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/metricProviders/JiraOpenIssuesProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/metricProviders/JiraOpenIssuesProvider.ts index e4116e91060..36ad05660bf 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/metricProviders/JiraOpenIssuesProvider.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/metricProviders/JiraOpenIssuesProvider.ts @@ -15,33 +15,41 @@ */ import type { Config } from '@backstage/config'; +import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; import type { Entity } from '@backstage/catalog-model'; -import { JIRA_CONFIG_PATH } from '../constants'; import { DEFAULT_NUMBER_THRESHOLDS, Metric, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; -import { JiraClient } from '../clients/base'; -import { JiraClientFactory } from '../clients/JiraClientFactory'; import { - type AuthService, - type DiscoveryService, -} from '@backstage/backend-plugin-api'; + buildJqlFiltersFromEntity, + OPEN_ISSUES_FILTER_ANNOTATIONS, +} from '../annotations'; +import { JiraClient } from '../clients/base'; import { - ConnectionStrategy, - DirectConnectionStrategy, - ProxyConnectionStrategy, -} from '../strategies/ConnectionStrategy'; -import { Product } from '../clients/types'; - -import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; + parseJiraOpenIssuesConfigOptions, + type JiraOpenIssuesOptions, +} from './JiraOpenIssuesConfig'; +import { buildOpenIssuesJql } from './openIssuesJql'; export class JiraOpenIssuesProvider implements MetricProvider<'number'> { private readonly jiraClient: JiraClient; + private readonly options: JiraOpenIssuesOptions; - private constructor(config: Config, connectionStrategy: ConnectionStrategy) { - this.jiraClient = JiraClientFactory.create(config, connectionStrategy); + private constructor(jiraClient: JiraClient, options: JiraOpenIssuesOptions) { + this.jiraClient = jiraClient; + this.options = options; + } + + static fromConfig( + config: Config, + options: { jiraClient: JiraClient }, + ): JiraOpenIssuesProvider { + return new JiraOpenIssuesProvider( + options.jiraClient, + parseJiraOpenIssuesConfigOptions(config), + ); } getCatalogFilter(): Record { @@ -72,37 +80,13 @@ export class JiraOpenIssuesProvider implements MetricProvider<'number'> { ]; } - static fromConfig( - config: Config, - options: { - auth: AuthService; - discovery: DiscoveryService; - }, - ): JiraOpenIssuesProvider { - let connectionStrategy: ConnectionStrategy; - - const jiraConfig = config.getConfig(JIRA_CONFIG_PATH); - const proxyPath = jiraConfig.getOptionalString('proxyPath'); - - if (proxyPath) { - connectionStrategy = new ProxyConnectionStrategy( - proxyPath, - options.auth, - options.discovery, - ); - } else { - connectionStrategy = new DirectConnectionStrategy( - jiraConfig.getString('baseUrl'), - jiraConfig.getString('token'), - jiraConfig.getString('product') as Product, - ); - } - - return new JiraOpenIssuesProvider(config, connectionStrategy); - } - async calculateMetrics(entity: Entity): Promise> { - const value = await this.jiraClient.getCountOpenIssues(entity); + const entityFilters = buildJqlFiltersFromEntity( + entity, + OPEN_ISSUES_FILTER_ANNOTATIONS, + ); + const jql = buildOpenIssuesJql(entityFilters, this.options); + const value = await this.jiraClient.getCountOpenIssues(jql); const results = new Map(); results.set(this.getProviderId(), value); return results; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/metricProviders/openIssuesJql.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/metricProviders/openIssuesJql.test.ts new file mode 100644 index 00000000000..bcc4c281b2d --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/metricProviders/openIssuesJql.test.ts @@ -0,0 +1,117 @@ +/* + * 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 { JiraJqlFilters } from '../annotations'; +import { buildOpenIssuesJql } from './openIssuesJql'; + +describe('buildOpenIssuesJql', () => { + const baseFilters: JiraJqlFilters = { + project: 'project = "MOON"', + }; + + const appConfigOptions = { + mandatoryFilter: 'type = Task AND resolution = Resolved', + customFilter: 'assignee = testerUser', + }; + + it('should use default mandatory filter when app-config options are unset', () => { + const jql = buildOpenIssuesJql(baseFilters, {}); + + expect(jql).toBe( + '(project = "MOON") AND (type = Bug AND resolution = Unresolved)', + ); + }); + + it('should use default mandatory filter when app-config mandatoryFilter is empty', () => { + const jql = buildOpenIssuesJql(baseFilters, { mandatoryFilter: ' ' }); + + expect(jql).toBe( + '(project = "MOON") AND (type = Bug AND resolution = Unresolved)', + ); + }); + + it('should apply app-config mandatoryFilter instead of default mandatory filter', () => { + const jql = buildOpenIssuesJql(baseFilters, { + mandatoryFilter: 'type = Bug AND resolution = Unresolved AND team = 4333', + }); + + expect(jql).toBe( + '(project = "MOON") AND (type = Bug AND resolution = Unresolved AND team = 4333)', + ); + }); + + it('should apply app-config customFilter with default mandatory filter', () => { + const jql = buildOpenIssuesJql(baseFilters, { + customFilter: 'team = 4316', + }); + + expect(jql).toBe( + '(project = "MOON") AND (type = Bug AND resolution = Unresolved) AND (team = 4316)', + ); + }); + + it('should apply app-config mandatoryFilter and customFilter', () => { + const jql = buildOpenIssuesJql(baseFilters, appConfigOptions); + + expect(jql).toBe( + '(project = "MOON") AND (type = Task AND resolution = Resolved) AND (assignee = testerUser)', + ); + }); + + it('should apply entity filters with default mandatory filter', () => { + const jql = buildOpenIssuesJql( + { + project: 'project = "MOON"', + component: 'component = "frontend"', + label: 'labels = "critical"', + }, + {}, + ); + + expect(jql).toBe( + '(project = "MOON") AND (component = "frontend") AND (labels = "critical") AND (type = Bug AND resolution = Unresolved)', + ); + }); + + it('should prefer entity custom-filter annotation over app-config customFilter', () => { + const jql = buildOpenIssuesJql( + { + ...baseFilters, + customFilter: 'assignee = Automobile', + }, + appConfigOptions, + ); + + expect(jql).toBe( + '(project = "MOON") AND (assignee = Automobile) AND (type = Task AND resolution = Resolved)', + ); + expect(jql).not.toContain('(assignee = testerUser)'); + }); + + it('should apply entity custom-filter with app-config mandatoryFilter', () => { + const jql = buildOpenIssuesJql( + { + ...baseFilters, + customFilter: 'assignee = Robot', + }, + { mandatoryFilter: 'resolution = Unresolved' }, + ); + + expect(jql).toBe( + '(project = "MOON") AND (assignee = Robot) AND (resolution = Unresolved)', + ); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/metricProviders/openIssuesJql.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/metricProviders/openIssuesJql.ts new file mode 100644 index 00000000000..d44867a47d2 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/metricProviders/openIssuesJql.ts @@ -0,0 +1,41 @@ +/* + * 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 { JiraJqlFilters } from '../annotations'; +import { joinJqlClauses } from '../clients/utils'; +import { JIRA_MANDATORY_FILTER } from '../constants'; +import type { JiraOpenIssuesOptions } from './JiraOpenIssuesConfig'; + +export function buildOpenIssuesJql( + entityFilters: JiraJqlFilters, + configOptions: JiraOpenIssuesOptions, +): string { + const { customFilter: annotationCustomFilter } = entityFilters; + const { mandatoryFilter, customFilter: optionsCustomFilter } = configOptions; + + const defaultFilterQuery = mandatoryFilter?.trim() || JIRA_MANDATORY_FILTER; + + const customFilterQuery = + !annotationCustomFilter && optionsCustomFilter?.trim() + ? optionsCustomFilter + : null; + + return joinJqlClauses([ + ...Object.values(entityFilters), + defaultFilterQuery, + customFilterQuery, + ]); +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/module.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/module.ts index ba149ef2986..ef7be3e1751 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/module.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/module.ts @@ -17,8 +17,13 @@ import { coreServices, createBackendModule, } from '@backstage/backend-plugin-api'; -import { scorecardMetricsExtensionPoint } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { + scorecardCollectorsExtensionPoint, + scorecardMetricsExtensionPoint, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { JiraIncidentsCollector } from './collectors/JiraIncidentsCollector'; import { JiraOpenIssuesProvider } from './metricProviders/JiraOpenIssuesProvider'; +import { JiraClientFactory } from './clients/JiraClientFactory'; export const scorecardModuleJira = createBackendModule({ pluginId: 'scorecard', @@ -27,13 +32,21 @@ export const scorecardModuleJira = createBackendModule({ reg.registerInit({ deps: { auth: coreServices.auth, + collectors: scorecardCollectorsExtensionPoint, config: coreServices.rootConfig, discovery: coreServices.discovery, + logger: coreServices.logger, metrics: scorecardMetricsExtensionPoint, }, - async init({ auth, config, discovery, metrics }) { + async init({ auth, collectors, config, discovery, logger, metrics }) { + const jiraClient = JiraClientFactory.fromConfig(config, { + auth, + discovery, + logger, + }); + collectors.addCollector(new JiraIncidentsCollector(jiraClient)); metrics.addMetricProvider( - JiraOpenIssuesProvider.fromConfig(config, { auth, discovery }), + JiraOpenIssuesProvider.fromConfig(config, { jiraClient }), ); }, }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/strategies/ConnectionStrategy.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/strategies/ConnectionStrategy.test.ts index 6087af3f5fe..54b12fd7011 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/strategies/ConnectionStrategy.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-jira/src/strategies/ConnectionStrategy.test.ts @@ -31,7 +31,7 @@ describe('ConnectionStrategy', () => { describe('DirectConnectionStrategy', () => { const connectionStrategy = new DirectConnectionStrategy( 'https://example.com/api', - 'Fds31dsF32', + 'dummyToken', 'cloud', ); @@ -51,17 +51,17 @@ describe('ConnectionStrategy', () => { describe('getAuthHeaders', () => { it('should return Basic auth headers when product is cloud', async () => { const authHeaders = await connectionStrategy.getAuthHeaders(); - expect(authHeaders).toEqual({ Authorization: 'Basic Fds31dsF32' }); + expect(authHeaders).toEqual({ Authorization: 'Basic dummyToken' }); }); it('should return Bearer auth headers when product is datacenter', async () => { const dataCenterStrategy = new DirectConnectionStrategy( 'https://example.com/api', - 'Fds31dsF32', + 'dummyToken', 'datacenter', ); const authHeaders = await dataCenterStrategy.getAuthHeaders(); - expect(authHeaders).toEqual({ Authorization: 'Bearer Fds31dsF32' }); + expect(authHeaders).toEqual({ Authorization: 'Bearer dummyToken' }); }); }); }); @@ -92,10 +92,10 @@ describe('ConnectionStrategy', () => { it('should return Bearer auth headers when service token is present', async () => { jest .spyOn(mockAuth, 'getPluginRequestToken') - .mockResolvedValue({ token: 'Fds31dsF32' }); + .mockResolvedValue({ token: 'dummyToken' }); const authHeaders = await connectionStrategy.getAuthHeaders(); - expect(authHeaders).toEqual({ Authorization: 'Bearer Fds31dsF32' }); + expect(authHeaders).toEqual({ Authorization: 'Bearer dummyToken' }); }); it('should return an empty object when service token is not present', async () => { @@ -112,12 +112,12 @@ describe('ConnectionStrategy', () => { it('should return the service token', async () => { jest .spyOn(mockAuth, 'getPluginRequestToken') - .mockResolvedValue({ token: 'Fds31dsF32' }); + .mockResolvedValue({ token: 'dummyToken' }); const serviceToken = await ( connectionStrategy as any ).getServiceToken(); - expect(serviceToken).toEqual('Fds31dsF32'); + expect(serviceToken).toEqual('dummyToken'); }); }); }); 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 0e5b1437ac1..b94361cc58c 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 @@ -14,15 +14,9 @@ * limitations under the License. */ -import type { Entity } from '@backstage/catalog-model'; +import { mockServices } from '@backstage/backend-test-utils'; +import { z } from 'zod'; import { JiraCloudClientStrategy } from './JiraCloudClientStrategy'; -import { ScorecardJiraAnnotations } from '../annotations'; -import { - newEntityComponent, - newMockRootConfig, -} from '../../__fixtures__/testUtils'; - -const { PROJECT_KEY } = ScorecardJiraAnnotations; globalThis.fetch = jest.fn(); @@ -30,25 +24,28 @@ const mockConnectionStrategy = { getBaseUrl: jest.fn().mockReturnValue('https://example.com/api/rest/api/3'), getAuthHeaders: jest .fn() - .mockResolvedValue({ Authorization: 'Basic Fds31dsF32' }), + .mockResolvedValue({ Authorization: 'Basic dummyToken' }), }; describe('JiraCloudClient', () => { let jiraCloudClient: JiraCloudClientStrategy; + const mockedLogger = mockServices.logger.mock(); beforeEach(() => { - const config = newMockRootConfig({ - options: { mandatoryFilter: 'Type = Bug' }, - }); - jiraCloudClient = new JiraCloudClientStrategy( - config, mockConnectionStrategy, + mockedLogger, ); }); afterEach(() => { - jest.clearAllMocks(); + jest.resetAllMocks(); + mockConnectionStrategy.getBaseUrl.mockReturnValue( + 'https://example.com/api/rest/api/3', + ); + mockConnectionStrategy.getAuthHeaders.mockResolvedValue({ + Authorization: 'Basic dummyToken', + }); }); describe('constructor', () => { @@ -57,10 +54,12 @@ describe('JiraCloudClient', () => { }); }); - describe('getSearchEndpoint', () => { - it('should return correct search endpoint', () => { - const searchEndpoint = (jiraCloudClient as any).getSearchEndpoint(); - expect(searchEndpoint).toEqual('/search/approximate-count'); + describe('getSearchCountEndpoint', () => { + it('should return correct search count endpoint', () => { + const searchCountEndpoint = ( + jiraCloudClient as any + ).getSearchCountEndpoint(); + expect(searchCountEndpoint).toEqual('/search/approximate-count'); }); }); @@ -90,15 +89,15 @@ describe('JiraCloudClient', () => { }); describe('getCountOpenIssues', () => { - const mockEntity: Entity = newEntityComponent({ [PROJECT_KEY]: 'TEST' }); - - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValueOnce({ count: 5 }), - }); - it('should get count with Basic auth header', async () => { - const count = await jiraCloudClient.getCountOpenIssues(mockEntity); + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ count: 5 }), + }); + + const count = await jiraCloudClient.getCountOpenIssues( + 'project = "TEST"', + ); expect(count).toBe(5); }); }); @@ -109,4 +108,239 @@ describe('JiraCloudClient', () => { expect(apiVersion).toEqual(3); }); }); + + describe('sendPaginatedRequest', () => { + const responseSchema = z.object({ + items: z.array(z.object({ id: z.string() })), + }); + + it('should return mapped results from a single page', async () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + items: [{ id: 'a' }, { id: 'b' }], + isLast: true, + }), + }); + + const results = await jiraCloudClient.sendPaginatedRequest({ + url: 'https://example.com/api/rest/api/3/search/jql', + method: 'POST', + body: { jql: 'project = "INC"' }, + responseSchema, + mapper: page => page.items.map(item => item.id), + }); + + expect(results).toEqual(['a', 'b']); + expect( + JSON.parse((globalThis.fetch as jest.Mock).mock.calls[0][1].body), + ).toEqual({ jql: 'project = "INC"' }); + }); + + it('should page with nextPageToken and flatten mapped results', async () => { + (globalThis.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + items: [{ id: 'a' }], + nextPageToken: 'token-2', + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + items: [{ id: 'b' }], + isLast: true, + }), + }); + + const results = await jiraCloudClient.sendPaginatedRequest({ + url: 'https://example.com/api/rest/api/3/search/jql', + method: 'POST', + body: { jql: 'project = "INC"' }, + responseSchema, + mapper: page => page.items.map(item => item.id), + }); + + expect(results).toEqual(['a', 'b']); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + expect( + JSON.parse((globalThis.fetch as jest.Mock).mock.calls[0][1].body), + ).not.toHaveProperty('nextPageToken'); + expect( + JSON.parse((globalThis.fetch as jest.Mock).mock.calls[1][1].body) + .nextPageToken, + ).toBe('token-2'); + }); + + it('should stop paging when fetch limit is reached', async () => { + (globalThis.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + items: [{ id: 'a' }, { id: 'b' }], + nextPageToken: 'token-2', + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + items: [{ id: 'c' }], + isLast: true, + }), + }); + + const results = await jiraCloudClient.sendPaginatedRequest({ + url: 'https://example.com/api/rest/api/3/search/jql', + method: 'POST', + body: { jql: 'project = "INC"' }, + responseSchema, + mapper: page => page.items.map(item => item.id), + fetchItemsLimit: 2, + }); + + expect(results).toEqual(['a', 'b']); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect(mockedLogger.warn).toHaveBeenCalledWith( + 'Reached fetchItemsLimit of 2 for Jira request to https://example.com/api/rest/api/3/search/jql; stopping fetch', + ); + }); + + it('should slice the last page when it exceeds remaining fetchItemsLimit', async () => { + (globalThis.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + items: [{ id: 'a' }, { id: 'b' }], + nextPageToken: 'token-2', + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + items: [{ id: 'c' }, { id: 'd' }], + isLast: true, + }), + }); + + const results = await jiraCloudClient.sendPaginatedRequest({ + url: 'https://example.com/api/rest/api/3/search/jql', + method: 'POST', + body: { jql: 'project = "INC"' }, + responseSchema, + mapper: page => page.items.map(item => item.id), + fetchItemsLimit: 3, + }); + + expect(results).toEqual(['a', 'b', 'c']); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + expect(mockedLogger.warn).toHaveBeenCalledWith( + 'Reached fetchItemsLimit of 3 for Jira request to https://example.com/api/rest/api/3/search/jql; stopping fetch', + ); + }); + + it('should warn when fetchItemsLimit truncates the last page', async () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + items: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + isLast: true, + }), + }); + + const results = await jiraCloudClient.sendPaginatedRequest({ + url: 'https://example.com/api/rest/api/3/search/jql', + method: 'POST', + body: { jql: 'project = "INC"' }, + responseSchema, + mapper: page => page.items.map(item => item.id), + fetchItemsLimit: 2, + }); + + expect(results).toEqual(['a', 'b']); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect(mockedLogger.warn).toHaveBeenCalledWith( + 'Reached fetchItemsLimit of 2 for Jira request to https://example.com/api/rest/api/3/search/jql; stopping fetch', + ); + }); + + it('should throw when response does not match schema', async () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ items: 'bad' }), + }); + + await expect( + jiraCloudClient.sendPaginatedRequest({ + url: 'https://example.com/api/rest/api/3/search/jql', + method: 'POST', + responseSchema, + mapper: page => page.items.map(item => item.id), + }), + ).rejects.toThrow( + 'Incorrect response data from https://example.com/api/rest/api/3/search/jql', + ); + }); + + it('should throw when paging fields are invalid', async () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + items: [{ id: 'a' }], + nextPageToken: 123, + }), + }); + + await expect( + jiraCloudClient.sendPaginatedRequest({ + url: 'https://example.com/api/rest/api/3/search/jql', + method: 'POST', + responseSchema, + mapper: page => page.items.map(item => item.id), + }), + ).rejects.toThrow( + 'Incorrect response data from https://example.com/api/rest/api/3/search/jql', + ); + }); + }); + + describe('getIssues', () => { + it('should return mapped Jira issues from /search/jql', async () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + issues: [ + { + id: '10001', + fields: { + created: '2026-06-01T10:00:00.000+0530', + resolutiondate: '2026-06-01T12:00:00.000+0530', + }, + }, + ], + isLast: true, + }), + }); + + const issues = await jiraCloudClient.getIssues( + '(project = "INC") AND (type = "Incident")', + ); + const requestUrl = (globalThis.fetch as jest.Mock).mock.calls[0][0]; + const requestBody = JSON.parse( + (globalThis.fetch as jest.Mock).mock.calls[0][1].body, + ); + + expect(requestUrl).toBe('https://example.com/api/rest/api/3/search/jql'); + expect(issues).toEqual([ + { + id: '10001', + createdAt: '2026-06-01T04:30:00.000Z', + resolutionAt: '2026-06-01T06:30:00.000Z', + }, + ]); + expect(requestBody.jql).toContain('project = "INC"'); + expect(requestBody.fields).toEqual(['created', '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 b58ef631526..dace0e187ec 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 @@ -14,11 +14,93 @@ * limitations under the License. */ +import type { JsonObject } from '@backstage/types'; +import { z } from 'zod'; import { JiraClient } from '../clients/base'; -import { CLOUD_API_VERSION } from '../constants'; +import { mapJiraIssues } from '../clients/mappers'; +import { jiraSearchIssueSchema } from '../clients/schemas/jiraSearchIssue'; +import type { JiraIssue, Method } from '../clients/types'; +import { + CLOUD_API_VERSION, + DEFAULT_PAGINATED_FETCH_ITEMS_LIMIT, +} from '../constants'; export class JiraCloudClientStrategy extends JiraClient { - protected getSearchEndpoint(): string { + public async sendPaginatedRequest(options: { + url: string; + method: Method; + body?: JsonObject; + responseSchema: z.ZodType; + mapper: (page: TPage) => TOut[]; + fetchItemsLimit?: number; + }): Promise { + const fetchItemsLimit = + options.fetchItemsLimit ?? DEFAULT_PAGINATED_FETCH_ITEMS_LIMIT; + const results: TOut[] = []; + let nextPageToken: string | undefined; + let hasMorePages = true; + const headers = await this.getAuthHeaders(); + + const cloudPagingSchema = z.object({ + nextPageToken: z.string().optional(), + isLast: z.boolean().optional(), + }); + + while (hasMorePages && results.length < fetchItemsLimit) { + const requestBody: JsonObject = { + ...options.body, + ...(nextPageToken ? { nextPageToken } : {}), + }; + + const data = await this.sendRequest({ + method: options.method, + url: options.url, + headers, + body: JSON.stringify(requestBody), + }); + + let page: TPage; + let paging: z.infer; + try { + page = options.responseSchema.parse(data); + paging = cloudPagingSchema.parse(data); + } catch (error) { + throw new Error( + `Incorrect response data from ${options.url}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + const remaining = fetchItemsLimit - results.length; + const mapped = options.mapper(page); + const token = paging.nextPageToken?.trim(); + const hasMorePagesAvailable = Boolean(token) && paging.isLast !== true; + + if (mapped.length >= remaining) { + results.push(...mapped.slice(0, remaining)); + if (mapped.length > remaining || hasMorePagesAvailable) { + this.logger.warn( + `Reached fetchItemsLimit of ${fetchItemsLimit} for Jira request to ${options.url}; stopping fetch`, + ); + } + break; + } + + results.push(...mapped); + + if (!hasMorePagesAvailable) { + hasMorePages = false; + } else { + nextPageToken = token; + hasMorePages = true; + } + } + + return results; + } + + protected getSearchCountEndpoint(): string { return '/search/approximate-count'; } @@ -42,4 +124,20 @@ export class JiraCloudClientStrategy extends JiraClient { protected getApiVersion(): number { return CLOUD_API_VERSION; } + + public async getIssues(jql: string): Promise { + const baseUrl = await this.getBaseUrl(); + return this.sendPaginatedRequest({ + url: `${baseUrl}/search/jql`, + method: 'POST', + body: { + jql, + fields: ['created', 'resolutiondate'], + }, + responseSchema: z.object({ + issues: z.array(jiraSearchIssueSchema), + }), + mapper: page => mapJiraIssues(page.issues), + }); + } } 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 61ce36131fb..5a5c8bf007b 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 @@ -14,46 +14,38 @@ * limitations under the License. */ -import type { Entity } from '@backstage/catalog-model'; -import { ScorecardJiraAnnotations } from '../annotations'; +import { mockServices } from '@backstage/backend-test-utils'; +import { z } from 'zod'; import { JiraDataCenterClientStrategy } from './JiraDataCenterClientStrategy'; -import { - newEntityComponent, - newMockRootConfig, -} from '../../__fixtures__/testUtils'; globalThis.fetch = jest.fn(); -const { PROJECT_KEY } = ScorecardJiraAnnotations; - const mockConnectionStrategy = { getBaseUrl: jest.fn().mockReturnValue('https://example.com/api/rest/api/2'), getAuthHeaders: jest .fn() - .mockResolvedValue({ Authorization: 'Bearer Fds31dsF32' }), + .mockResolvedValue({ Authorization: 'Bearer dummyToken' }), }; describe('JiraDataCenterClient', () => { let jiraDataCenterClient: JiraDataCenterClientStrategy; + const mockedLogger = mockServices.logger.mock(); beforeEach(() => { - const options = { - mandatoryFilter: 'Type = Task', - customFilter: 'priority in ("Critical", "Blocker")', - }; - const config = newMockRootConfig({ - options, - jiraConfig: { product: 'datacenter' }, - }); - jiraDataCenterClient = new JiraDataCenterClientStrategy( - config, mockConnectionStrategy, + mockedLogger, ); }); afterEach(() => { - jest.clearAllMocks(); + jest.resetAllMocks(); + mockConnectionStrategy.getBaseUrl.mockReturnValue( + 'https://example.com/api/rest/api/2', + ); + mockConnectionStrategy.getAuthHeaders.mockResolvedValue({ + Authorization: 'Bearer dummyToken', + }); }); describe('constructor', () => { @@ -62,13 +54,6 @@ describe('JiraDataCenterClient', () => { }); }); - describe('getSearchEndpoint', () => { - it('should return correct search endpoint', () => { - const searchEndpoint = (jiraDataCenterClient as any).getSearchEndpoint(); - expect(searchEndpoint).toEqual('/search'); - }); - }); - describe('buildSearchBody', () => { it('should return correct search body', () => { const searchBody = (jiraDataCenterClient as any).buildSearchBody( @@ -99,17 +84,15 @@ describe('JiraDataCenterClient', () => { }); describe('getCountOpenIssues', () => { - const mockEntity: Entity = newEntityComponent({ - [PROJECT_KEY]: 'DATACENTER', - }); - - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValueOnce({ total: 10 }), - }); - it('should get count of open issues', async () => { - const count = await jiraDataCenterClient.getCountOpenIssues(mockEntity); + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ total: 10 }), + }); + + const count = await jiraDataCenterClient.getCountOpenIssues( + 'project = "DATACENTER"', + ); expect(count).toBe(10); }); }); @@ -120,4 +103,263 @@ describe('JiraDataCenterClient', () => { expect(apiVersion).toEqual(2); }); }); + + describe('sendPaginatedRequest', () => { + const responseSchema = z.object({ + items: z.array(z.object({ id: z.string() })), + }); + + it('should return mapped results from a single page', async () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + startAt: 0, + maxResults: 50, + total: 2, + items: [{ id: 'a' }, { id: 'b' }], + }), + }); + + const results = await jiraDataCenterClient.sendPaginatedRequest({ + url: 'https://example.com/api/rest/api/2/search', + method: 'POST', + body: { jql: 'project = "INC"' }, + responseSchema, + mapper: page => page.items.map(item => item.id), + }); + + expect(results).toEqual(['a', 'b']); + expect( + JSON.parse((globalThis.fetch as jest.Mock).mock.calls[0][1].body), + ).toEqual({ jql: 'project = "INC"', startAt: 0 }); + }); + + it('should page with startAt and flatten mapped results', async () => { + (globalThis.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + startAt: 0, + maxResults: 1, + total: 2, + items: [{ id: 'a' }], + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + startAt: 1, + maxResults: 1, + total: 2, + items: [{ id: 'b' }], + }), + }); + + const results = await jiraDataCenterClient.sendPaginatedRequest({ + url: 'https://example.com/api/rest/api/2/search', + method: 'POST', + body: { jql: 'project = "INC"' }, + responseSchema, + mapper: page => page.items.map(item => item.id), + }); + + expect(results).toEqual(['a', 'b']); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + expect( + JSON.parse((globalThis.fetch as jest.Mock).mock.calls[0][1].body) + .startAt, + ).toBe(0); + expect( + JSON.parse((globalThis.fetch as jest.Mock).mock.calls[1][1].body) + .startAt, + ).toBe(1); + }); + + it('should stop paging when fetch limit is reached', async () => { + (globalThis.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + startAt: 0, + maxResults: 2, + total: 4, + items: [{ id: 'a' }, { id: 'b' }], + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + startAt: 2, + maxResults: 2, + total: 4, + items: [{ id: 'c' }, { id: 'd' }], + }), + }); + + const results = await jiraDataCenterClient.sendPaginatedRequest({ + url: 'https://example.com/api/rest/api/2/search', + method: 'POST', + body: { jql: 'project = "INC"' }, + responseSchema, + mapper: page => page.items.map(item => item.id), + fetchItemsLimit: 2, + }); + + expect(results).toEqual(['a', 'b']); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect(mockedLogger.warn).toHaveBeenCalledWith( + 'Reached fetchItemsLimit of 2 for Jira request to https://example.com/api/rest/api/2/search; stopping fetch', + ); + }); + + it('should slice the last page when it exceeds remaining fetchItemsLimit', async () => { + (globalThis.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + startAt: 0, + maxResults: 2, + total: 5, + items: [{ id: 'a' }, { id: 'b' }], + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + startAt: 2, + maxResults: 3, + total: 5, + items: [{ id: 'c' }, { id: 'd' }], + }), + }); + + const results = await jiraDataCenterClient.sendPaginatedRequest({ + url: 'https://example.com/api/rest/api/2/search', + method: 'POST', + body: { jql: 'project = "INC"' }, + responseSchema, + mapper: page => page.items.map(item => item.id), + fetchItemsLimit: 3, + }); + + expect(results).toEqual(['a', 'b', 'c']); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + expect(mockedLogger.warn).toHaveBeenCalledWith( + 'Reached fetchItemsLimit of 3 for Jira request to https://example.com/api/rest/api/2/search; stopping fetch', + ); + }); + + it('should warn when fetchItemsLimit truncates the last page', async () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + startAt: 0, + maxResults: 3, + total: 3, + items: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + }), + }); + + const results = await jiraDataCenterClient.sendPaginatedRequest({ + url: 'https://example.com/api/rest/api/2/search', + method: 'POST', + body: { jql: 'project = "INC"' }, + responseSchema, + mapper: page => page.items.map(item => item.id), + fetchItemsLimit: 2, + }); + + expect(results).toEqual(['a', 'b']); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect(mockedLogger.warn).toHaveBeenCalledWith( + 'Reached fetchItemsLimit of 2 for Jira request to https://example.com/api/rest/api/2/search; stopping fetch', + ); + }); + + it('should throw when paging fields are missing', async () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + items: [{ id: 'a' }], + }), + }); + + await expect( + jiraDataCenterClient.sendPaginatedRequest({ + url: 'https://example.com/api/rest/api/2/search', + method: 'POST', + responseSchema, + mapper: page => page.items.map(item => item.id), + }), + ).rejects.toThrow( + 'Incorrect response data from https://example.com/api/rest/api/2/search', + ); + }); + + it('should throw when response does not match schema', async () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + startAt: 0, + maxResults: 50, + total: 1, + items: 'bad', + }), + }); + + await expect( + jiraDataCenterClient.sendPaginatedRequest({ + url: 'https://example.com/api/rest/api/2/search', + method: 'POST', + responseSchema, + mapper: page => page.items.map(item => item.id), + }), + ).rejects.toThrow( + 'Incorrect response data from https://example.com/api/rest/api/2/search', + ); + }); + }); + + describe('getIssues', () => { + it('should return mapped Jira issues from /search', async () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + startAt: 0, + maxResults: 50, + total: 1, + issues: [ + { + id: '10001', + fields: { + created: '2026-06-01T10:00:00.000+0530', + resolutiondate: '2026-06-01T12:00:00.000+0530', + }, + }, + ], + }), + }); + + const issues = await jiraDataCenterClient.getIssues( + '(project = "INC") AND (type = "Incident")', + ); + const requestUrl = (globalThis.fetch as jest.Mock).mock.calls[0][0]; + const requestBody = JSON.parse( + (globalThis.fetch as jest.Mock).mock.calls[0][1].body, + ); + + expect(requestUrl).toBe('https://example.com/api/rest/api/2/search'); + expect(issues).toEqual([ + { + id: '10001', + createdAt: '2026-06-01T04:30:00.000Z', + resolutionAt: '2026-06-01T06:30:00.000Z', + }, + ]); + expect(requestBody.jql).toContain('project = "INC"'); + expect(requestBody.fields).toEqual(['created', '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 af2b826e011..bf8fd01557c 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 @@ -14,11 +14,91 @@ * limitations under the License. */ +import type { JsonObject } from '@backstage/types'; +import { z } from 'zod'; import { JiraClient } from '../clients/base'; -import { DATA_CENTER_API_VERSION } from '../constants'; +import { mapJiraIssues } from '../clients/mappers'; +import { jiraSearchIssueSchema } from '../clients/schemas/jiraSearchIssue'; +import type { JiraIssue, Method } from '../clients/types'; +import { + DATA_CENTER_API_VERSION, + DEFAULT_PAGINATED_FETCH_ITEMS_LIMIT, +} from '../constants'; export class JiraDataCenterClientStrategy extends JiraClient { - protected getSearchEndpoint(): string { + public async sendPaginatedRequest(options: { + url: string; + method: Method; + body?: JsonObject; + responseSchema: z.ZodType; + mapper: (page: TPage) => TOut[]; + fetchItemsLimit?: number; + }): Promise { + const fetchItemsLimit = + options.fetchItemsLimit ?? DEFAULT_PAGINATED_FETCH_ITEMS_LIMIT; + const results: TOut[] = []; + let startAt = 0; + let hasMorePages = true; + const headers = await this.getAuthHeaders(); + + const dataCenterPagingSchema = z.object({ + startAt: z.number(), + maxResults: z.number(), + total: z.number(), + }); + + while (hasMorePages && results.length < fetchItemsLimit) { + const requestBody: JsonObject = { + ...options.body, + startAt, + }; + + const data = await this.sendRequest({ + method: options.method, + url: options.url, + headers, + body: JSON.stringify(requestBody), + }); + + let page: TPage; + let paging: z.infer; + try { + page = options.responseSchema.parse(data); + paging = dataCenterPagingSchema.parse(data); + } catch (error) { + throw new Error( + `Incorrect response data from ${options.url}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + const remaining = fetchItemsLimit - results.length; + const mapped = options.mapper(page); + const nextStartAt = paging.startAt + paging.maxResults; + const hasMorePagesAvailable = + paging.maxResults > 0 && nextStartAt < paging.total; + + if (mapped.length >= remaining) { + results.push(...mapped.slice(0, remaining)); + if (mapped.length > remaining || hasMorePagesAvailable) { + this.logger.warn( + `Reached fetchItemsLimit of ${fetchItemsLimit} for Jira request to ${options.url}; stopping fetch`, + ); + } + break; + } + + results.push(...mapped); + + startAt = nextStartAt; + hasMorePages = hasMorePagesAvailable; + } + + return results; + } + + protected getSearchCountEndpoint(): string { return '/search'; } @@ -42,4 +122,20 @@ export class JiraDataCenterClientStrategy extends JiraClient { protected getApiVersion(): number { return DATA_CENTER_API_VERSION; } + + public async getIssues(jql: string): Promise { + const baseUrl = await this.getBaseUrl(); + return this.sendPaginatedRequest({ + url: `${baseUrl}/search`, + method: 'POST', + body: { + jql, + fields: ['created', 'resolutiondate'], + }, + responseSchema: z.object({ + issues: z.array(jiraSearchIssueSchema), + }), + mapper: page => mapJiraIssues(page.issues), + }); + } } diff --git a/workspaces/scorecard/plugins/scorecard-backend/README.md b/workspaces/scorecard/plugins/scorecard-backend/README.md index 75767f8063f..4d79cd6c4cc 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/README.md +++ b/workspaces/scorecard/plugins/scorecard-backend/README.md @@ -298,6 +298,57 @@ curl -X GET "{{url}}/api/scorecard/metrics/catalog/component/default/my-service? -H "Authorization: Bearer " ``` +### `GET /metrics/catalog/:kind/:namespace/:name/time-series` + +Returns daily time-series points for one metric on a catalog entity. Each point is the latest successful sample (`MAX(id)`) for that UTC calendar day. Calculation failures and null values are omitted. Returns `200` with `points: []` when the entity and metric are authorized but no data exists in the range. + +#### Path Parameters + +| Parameter | Type | Required | Description | +| ----------- | ------ | -------- | ---------------------------------- | +| `kind` | string | Yes | Entity kind (e.g., `component`) | +| `namespace` | string | Yes | Entity namespace (e.g., `default`) | +| `name` | string | Yes | Entity name | + +#### Query Parameters + +| Parameter | Type | Required | Description | +| ---------- | ------ | -------- | ------------------------------------------------- | +| `metricId` | string | Yes | Metric ID (e.g., `github.openPRs`) | +| `from` | string | Yes | Inclusive range start (ISO-8601) | +| `to` | string | Yes | Inclusive range end (ISO-8601); must be `>= from` | + +#### Permissions + +Requires `scorecard.metric.read` permission and `catalog.entity.read` permission for the specific entity. + +#### Example Request + +```bash +curl -X GET "{{url}}/api/scorecard/metrics/catalog/component/default/my-service/time-series?metricId=github.openPRs&from=2026-04-01T00:00:00.000Z&to=2026-04-30T23:59:59.999Z" \ + -H "Authorization: Bearer " +``` + +#### Example Response + +```json +{ + "metricId": "github.openPRs", + "entityRef": "component:default/my-service", + "metadata": { + "title": "GitHub open PRs", + "description": "The number of open pull requests.", + "type": "number", + "history": true, + "defaultVisualization": "value" + }, + "points": [ + { "value": 8, "timestamp": "2026-04-27T23:10:00.000Z" }, + { "value": 7, "timestamp": "2026-04-28T22:55:00.000Z" } + ] +} +``` + ### `GET /aggregations/:aggregationId` Returns aggregated metrics for the authenticated user across all catalog entities they own (same ownership rules as the legacy route; see [aggregation.md](./docs/aggregation.md)). diff --git a/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockDatabaseMetricValues.ts b/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockDatabaseMetricValues.ts index 1e5af80aee3..e6a5ac0745e 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockDatabaseMetricValues.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockDatabaseMetricValues.ts @@ -33,6 +33,7 @@ type BuildMockDatabaseMetricValuesParams = { export const mockDatabaseMetricValues = { createMetricValues: jest.fn(), readLatestEntityMetricValues: jest.fn(), + readEntityMetricValuesInRange: jest.fn(), cleanupExpiredMetrics: jest.fn(), readAggregatedMetricByEntityRefs: jest.fn(), readScalarAggregatedMetricByEntityRefs: jest.fn(), @@ -74,6 +75,8 @@ export const buildMockDatabaseMetricValues = ({ return { createMetricValues, readLatestEntityMetricValues, + readEntityMetricValuesInRange: + mockDatabaseMetricValues.readEntityMetricValuesInRange, cleanupExpiredMetrics, readAggregatedMetricByEntityRefs, readScalarAggregatedMetricByEntityRefs, diff --git a/workspaces/scorecard/plugins/scorecard-backend/docs/providers.md b/workspaces/scorecard/plugins/scorecard-backend/docs/providers.md index 34e81e44463..8cdb4c20979 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/docs/providers.md +++ b/workspaces/scorecard/plugins/scorecard-backend/docs/providers.md @@ -69,6 +69,8 @@ export class MyMetricProvider implements MetricProvider<'number'> { type: 'number', thresholds: DEFAULT_NUMBER_THRESHOLDS, history: true, + // Optional. Omit / undefined => 'value'. Use 'sparkline' when a time-series UI is intended. + defaultVisualization: 'sparkline', }, ]; } diff --git a/workspaces/scorecard/plugins/scorecard-backend/migrations/20260804123239_add_entity_metric_timestamp_index.js b/workspaces/scorecard/plugins/scorecard-backend/migrations/20260804123239_add_entity_metric_timestamp_index.js new file mode 100644 index 00000000000..8bebab2f2dd --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend/migrations/20260804123239_add_entity_metric_timestamp_index.js @@ -0,0 +1,33 @@ +/* + * 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.alterTable('metric_values', table => { + table.index( + ['catalog_entity_ref', 'metric_id', 'timestamp'], + 'idx_entity_metric_timestamp', + ); + }); +}; + +exports.down = async function down(knex) { + await knex.schema.alterTable('metric_values', table => { + table.dropIndex( + ['catalog_entity_ref', 'metric_id', 'timestamp'], + 'idx_entity_metric_timestamp', + ); + }); +}; diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/actions/getEntityMetrics.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/actions/getEntityMetrics.test.ts index b4c1ff1d2db..0c8c26257fd 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/actions/getEntityMetrics.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/actions/getEntityMetrics.test.ts @@ -47,6 +47,7 @@ describe('createGetEntityMetricsAction', () => { title: 'Open PRs', description: 'Number of open pull requests', type: 'number', + defaultVisualization: 'sparkline', }, result: { value: 5, diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/actions/getEntityMetrics.ts b/workspaces/scorecard/plugins/scorecard-backend/src/actions/getEntityMetrics.ts index 384cba9309b..40f158d71b0 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/actions/getEntityMetrics.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/actions/getEntityMetrics.ts @@ -61,6 +61,7 @@ export const createGetEntityMetricsAction = ({ description: z.string(), type: z.enum(['number', 'boolean']), history: z.boolean().optional(), + defaultVisualization: z.enum(['value', 'sparkline']).optional(), }), result: z.object({ value: z.union([z.number(), z.boolean(), z.null()]), diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/actions/listMetrics.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/actions/listMetrics.test.ts index 608ec9b1b91..7d9ec11fd82 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/actions/listMetrics.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/actions/listMetrics.test.ts @@ -42,12 +42,14 @@ describe('createListMetricsAction', () => { title: 'Open PRs', description: 'Number of open pull requests', type: 'number' as const, + defaultVisualization: 'sparkline' as const, }, { id: 'sonarqube.coverage', title: 'Code Coverage', description: 'Test coverage percentage', type: 'number' as const, + defaultVisualization: 'value' as const, }, ]; (mockRegistry.listMetrics as jest.Mock).mockReturnValue(metrics); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/actions/listMetrics.ts b/workspaces/scorecard/plugins/scorecard-backend/src/actions/listMetrics.ts index dc84bcf6338..6e5bae748e6 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/actions/listMetrics.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/actions/listMetrics.ts @@ -52,6 +52,7 @@ export const createListMetricsAction = ({ description: z.string(), type: z.enum(['number', 'boolean']), history: z.boolean().optional(), + defaultVisualization: z.enum(['value', 'sparkline']).optional(), }), ), }), diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/database/DatabaseMetricValues.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/database/DatabaseMetricValues.test.ts index 5b112214bac..39bcac4c0d8 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/database/DatabaseMetricValues.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/database/DatabaseMetricValues.test.ts @@ -196,6 +196,177 @@ describe('DatabaseMetricValues', () => { ); }); + describe('readEntityMetricValuesInRange', () => { + it.each(databases.eachSupportedId())( + 'should return rows in range ordered by timestamp then id - %p', + async databaseId => { + const { client, db } = await createDatabase(databaseId); + const entityRef = 'component:default/test-service'; + const metricId = 'github.metric1'; + + await client('metric_values').insert( + [ + createMetricValue({ + entityRef, + metricId, + value: 1, + timestamp: new Date('2023-01-01T10:00:00Z'), + }), + createMetricValue({ + entityRef, + metricId, + value: 2, + timestamp: new Date('2023-01-02T10:00:00Z'), + }), + createMetricValue({ + entityRef, + metricId, + value: 3, + timestamp: new Date('2023-01-03T10:00:00Z'), + }), + // outside range + createMetricValue({ + entityRef, + metricId, + value: 99, + timestamp: new Date('2023-01-05T10:00:00Z'), + }), + // different entity + createMetricValue({ + entityRef: 'component:default/other-service', + metricId, + value: 50, + timestamp: new Date('2023-01-02T12:00:00Z'), + }), + // different metric + createMetricValue({ + entityRef, + metricId: 'github.metric2', + value: 50, + timestamp: new Date('2023-01-02T12:00:00Z'), + }), + ].map(toMetricValueRow), + ); + + const result = await db.readEntityMetricValuesInRange( + entityRef, + metricId, + new Date('2023-01-01T00:00:00Z'), + new Date('2023-01-03T23:59:59Z'), + ); + + expect(result).toHaveLength(3); + expect(result.map(r => r.value)).toEqual([1, 2, 3]); + expect(result.every(r => r.catalogEntityRef === entityRef)).toBe(true); + expect(result.every(r => r.metricId === metricId)).toBe(true); + }, + ); + + it.each(databases.eachSupportedId())( + 'should include multiple samples on the same UTC day - %p', + async databaseId => { + const { client, db } = await createDatabase(databaseId); + const entityRef = 'component:default/test-service'; + const metricId = 'github.metric1'; + + await client('metric_values').insert( + [ + createMetricValue({ + entityRef, + metricId, + value: 8, + timestamp: new Date('2023-01-01T08:00:00Z'), + }), + createMetricValue({ + entityRef, + metricId, + value: 9, + timestamp: new Date('2023-01-01T20:00:00Z'), + }), + ].map(toMetricValueRow), + ); + + const result = await db.readEntityMetricValuesInRange( + entityRef, + metricId, + new Date('2023-01-01T00:00:00Z'), + new Date('2023-01-01T23:59:59Z'), + ); + + expect(result).toHaveLength(2); + expect(result[0].value).toBe(8); + expect(result[1].value).toBe(9); + // Postgres returns bigIncrements as strings; SQLite returns numbers + expect(Number(result[1].id)).toBeGreaterThan(Number(result[0].id)); + }, + ); + + it.each(databases.eachSupportedId())( + 'should include inclusive range bounds - %p', + async databaseId => { + const { client, db } = await createDatabase(databaseId); + const entityRef = 'component:default/test-service'; + const metricId = 'github.metric1'; + const from = new Date('2023-01-01T12:00:00Z'); + const to = new Date('2023-01-02T12:00:00Z'); + + await client('metric_values').insert( + [ + createMetricValue({ + entityRef, + metricId, + value: 1, + timestamp: from, + }), + createMetricValue({ + entityRef, + metricId, + value: 2, + timestamp: to, + }), + createMetricValue({ + entityRef, + metricId, + value: 3, + timestamp: new Date('2023-01-01T11:59:59Z'), + }), + createMetricValue({ + entityRef, + metricId, + value: 4, + timestamp: new Date('2023-01-02T12:00:01Z'), + }), + ].map(toMetricValueRow), + ); + + const result = await db.readEntityMetricValuesInRange( + entityRef, + metricId, + from, + to, + ); + + expect(result.map(r => r.value)).toEqual([1, 2]); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return empty array when no data in range - %p', + async databaseId => { + const { db } = await createDatabase(databaseId); + + const result = await db.readEntityMetricValuesInRange( + 'component:default/test-service', + 'github.metric1', + new Date('2023-01-01T00:00:00Z'), + new Date('2023-01-02T00:00:00Z'), + ); + + expect(result).toEqual([]); + }, + ); + }); + describe('cleanupExpiredMetrics', () => { it.each(databases.eachSupportedId())( 'should delete metric values that are older than the given date - %p', diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/database/DatabaseMetricValues.ts b/workspaces/scorecard/plugins/scorecard-backend/src/database/DatabaseMetricValues.ts index 63dca5f2eab..8ee051d48c1 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/database/DatabaseMetricValues.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/database/DatabaseMetricValues.ts @@ -228,6 +228,30 @@ export class DatabaseMetricValues { return (rows as MetricValueRowWithId[]).map(fromMetricValueRow); } + /** + * Get metric values for a specific entity and metric within a timestamp range. + * Ordered by timestamp ascending, then id ascending. + */ + async readEntityMetricValuesInRange( + catalogEntityRef: string, + metricId: string, + from: Date, + to: Date, + ): Promise { + const rows = await this.dbClient(this.tableName) + .select('*') + .where('catalog_entity_ref', catalogEntityRef) + .where('metric_id', metricId) + .where('timestamp', '>=', from) + .where('timestamp', '<=', to) + .orderBy([ + { column: 'timestamp', order: 'asc' }, + { column: 'id', order: 'asc' }, + ]); + + return (rows as MetricValueRowWithId[]).map(fromMetricValueRow); + } + /** * Delete metric values that are older than the given date */ diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/middlewares/validateQueryAndParams.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/middlewares/validateQueryAndParams.test.ts index 8528411ed96..c79b756c361 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/middlewares/validateQueryAndParams.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/middlewares/validateQueryAndParams.test.ts @@ -19,6 +19,7 @@ import type { Request, Response } from 'express'; import { validateAggregationIdParam } from './validateAggregationIdParam'; import { validateMetricIdsQueryParams } from './validateMetricIdsQueryParams'; import { validateDatasourceQueryParams } from './validateDatasourceQueryParams'; +import { validateTimeSeriesQueryParams } from './validateTimeSeriesQueryParams'; function mockReq(overrides: Partial = {}): Request { return { @@ -163,4 +164,62 @@ describe('Validators', () => { expect(next).not.toHaveBeenCalled(); }); }); + + describe('validateTimeSeriesQueryParams', () => { + const validQuery = { + metricId: 'github.openPRs', + from: '2024-01-01T00:00:00.000Z', + to: '2024-01-31T23:59:59.000Z', + }; + + it('should call next when all query params are valid', () => { + const req = mockReq({ query: validQuery }); + + validateTimeSeriesQueryParams(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(); + }); + + it.each([ + ['missing metricId', { from: validQuery.from, to: validQuery.to }], + [ + 'empty metricId', + { metricId: '', from: validQuery.from, to: validQuery.to }, + ], + ['missing from', { metricId: validQuery.metricId, to: validQuery.to }], + ['missing to', { metricId: validQuery.metricId, from: validQuery.from }], + ['invalid from', { ...validQuery, from: 'not-a-date' }], + ['invalid to', { ...validQuery, to: 'not-a-date' }], + [ + 'from after to', + { + ...validQuery, + from: '2024-02-01T00:00:00.000Z', + to: '2024-01-01T00:00:00.000Z', + }, + ], + ])('should throw InputError when %s', (_label, query) => { + const req = mockReq({ query: query as any }); + + expect(() => validateTimeSeriesQueryParams(req, res, next)).toThrow( + InputError, + ); + expect(next).not.toHaveBeenCalled(); + }); + + it('should call next when from equals to', () => { + const req = mockReq({ + query: { + metricId: 'github.openPRs', + from: '2024-01-01T00:00:00.000Z', + to: '2024-01-01T00:00:00.000Z', + }, + }); + + validateTimeSeriesQueryParams(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/middlewares/validateTimeSeriesQueryParams.ts b/workspaces/scorecard/plugins/scorecard-backend/src/middlewares/validateTimeSeriesQueryParams.ts new file mode 100644 index 00000000000..69069e0a81c --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend/src/middlewares/validateTimeSeriesQueryParams.ts @@ -0,0 +1,44 @@ +/* + * 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 { z } from 'zod'; +import { InputError } from '@backstage/errors'; +import type { Request, Response, NextFunction } from 'express'; + +export function validateTimeSeriesQueryParams( + req: Request, + _res: Response, + next: NextFunction, +): void { + const schema = z + .object({ + metricId: z.string().min(1).max(255), + from: z.string().datetime(), + to: z.string().datetime(), + }) + .refine(data => new Date(data.from) <= new Date(data.to), { + message: 'from must be less than or equal to to', + path: ['from'], + }); + + const parsed = schema.safeParse(req.query); + + if (!parsed.success) { + throw new InputError(`Invalid query parameters: ${parsed.error.message}`); + } + + next(); +} diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/plugin.api.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/plugin.api.test.ts index d5ad249091b..d9418962092 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/plugin.api.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/plugin.api.test.ts @@ -221,6 +221,35 @@ describe('scorecard plugin (startTestBackend)', () => { }); }); + describe('GET /api/scorecard/metrics/catalog/:kind/:namespace/:name/time-series', () => { + it('returns an empty time series for an existing entity with no stored samples', async () => { + const res = await request(server).get( + '/api/scorecard/metrics/catalog/component/default/my-service/time-series?metricId=github.openPRs&from=2024-01-01T00:00:00.000Z&to=2024-01-31T23:59:59.000Z', + ); + + expect(res.status).toBe(200); + expect(res.body).toEqual( + expect.objectContaining({ + metricId: 'github.openPRs', + entityRef: 'component:default/my-service', + points: [], + metadata: expect.objectContaining({ + title: 'GitHub Open PRs', + type: 'number', + }), + }), + ); + }); + + it('returns 404 when entity does not exist in the catalog', async () => { + const res = await request(server).get( + '/api/scorecard/metrics/catalog/component/default/non-existent/time-series?metricId=github.openPRs&from=2024-01-01T00:00:00.000Z&to=2024-01-31T23:59:59.000Z', + ); + + expect(res.status).toBe(404); + }); + }); + describe('GET /api/scorecard/aggregations/:aggregationId', () => { it('returns aggregated metrics for an authenticated user with owned entities', async () => { const res = await request(server).get( diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts index 9c44af17ec7..a37b7819055 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { NotFoundError } from '@backstage/errors'; +import { NotAllowedError, NotFoundError } from '@backstage/errors'; import { mockServices } from '@backstage/backend-test-utils'; import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; import { CatalogMetricService } from './CatalogMetricService'; @@ -404,6 +404,7 @@ describe('CatalogMetricService', () => { description: provider.getMetrics()[0].description, type: provider.getMetrics()[0].type, history: provider.getMetrics()[0].history, + defaultVisualization: provider.getMetrics()[0].defaultVisualization, }), ); expect(resultMetric.result).toEqual( @@ -442,6 +443,174 @@ describe('CatalogMetricService', () => { }); }); + describe('getEntityMetricTimeSeries', () => { + const from = new Date('2024-01-01T00:00:00.000Z'); + const to = new Date('2024-01-03T23:59:59.000Z'); + const entityRef = 'component:default/test-component'; + const metricId = 'github.importantMetric'; + + beforeEach(() => { + (permissionUtils.filterAuthorizedMetrics as jest.Mock).mockReturnValue([ + provider.getMetrics()[0], + ]); + mockedDatabase.readEntityMetricValuesInRange.mockResolvedValue([]); + }); + + it('should throw NotFoundError when entity is not found', async () => { + mockedCatalog.getEntityByRef.mockResolvedValue(undefined); + + await expect( + service.getEntityMetricTimeSeries(entityRef, metricId, from, to), + ).rejects.toThrow(new NotFoundError(`Entity not found: ${entityRef}`)); + }); + + it('should throw NotAllowedError when metric is not authorized', async () => { + (permissionUtils.filterAuthorizedMetrics as jest.Mock).mockReturnValue( + [], + ); + + await expect( + service.getEntityMetricTimeSeries( + entityRef, + metricId, + from, + to, + permissionsFilter, + ), + ).rejects.toThrow(NotAllowedError); + }); + + it('should return empty points when no data in range', async () => { + const result = await service.getEntityMetricTimeSeries( + entityRef, + metricId, + from, + to, + ); + + expect(result).toEqual({ + metricId, + entityRef, + points: [], + metadata: { + title: provider.getMetrics()[0].title, + description: provider.getMetrics()[0].description, + type: provider.getMetrics()[0].type, + history: provider.getMetrics()[0].history, + defaultVisualization: provider.getMetrics()[0].defaultVisualization, + }, + }); + expect(mockedDatabase.readEntityMetricValuesInRange).toHaveBeenCalledWith( + entityRef, + metricId, + from, + to, + ); + }); + + it('should fold multiple samples on the same UTC day to the highest id', async () => { + mockedDatabase.readEntityMetricValuesInRange.mockResolvedValue([ + { + id: 1, + catalogEntityRef: entityRef, + metricId: metricId, + value: 8, + timestamp: new Date('2024-01-01T08:00:00.000Z'), + errorMessage: null, + status: 'success', + }, + { + id: 3, + catalogEntityRef: entityRef, + metricId: metricId, + value: 9, + timestamp: new Date('2024-01-01T20:00:00.000Z'), + errorMessage: null, + status: 'success', + }, + { + id: 2, + catalogEntityRef: entityRef, + metricId: metricId, + value: 7, + timestamp: new Date('2024-01-02T12:00:00.000Z'), + errorMessage: null, + status: 'success', + }, + ] as DbMetricValue[]); + + const result = await service.getEntityMetricTimeSeries( + entityRef, + metricId, + from, + to, + ); + + expect(result.points).toEqual([ + { value: 9, timestamp: '2024-01-01T20:00:00.000Z' }, + { value: 7, timestamp: '2024-01-02T12:00:00.000Z' }, + ]); + }); + + it('should exclude null values and calculation errors from points', async () => { + mockedDatabase.readEntityMetricValuesInRange.mockResolvedValue([ + { + id: 1, + catalogEntityRef: entityRef, + metricId: metricId, + value: null, + timestamp: new Date('2024-01-01T10:00:00.000Z'), + errorMessage: 'Failed to calculate', + status: null, + }, + { + id: 2, + catalogEntityRef: entityRef, + metricId: metricId, + value: null, + timestamp: new Date('2024-01-02T10:00:00.000Z'), + errorMessage: null, + status: 'success', + }, + { + id: 3, + catalogEntityRef: entityRef, + metricId: metricId, + value: 5, + timestamp: new Date('2024-01-03T10:00:00.000Z'), + errorMessage: null, + status: 'success', + }, + ] as DbMetricValue[]); + + const result = await service.getEntityMetricTimeSeries( + entityRef, + metricId, + from, + to, + ); + + expect(result.points).toEqual([ + { value: 5, timestamp: '2024-01-03T10:00:00.000Z' }, + ]); + }); + + it('should pass permission filter to filterAuthorizedMetrics', async () => { + await service.getEntityMetricTimeSeries( + entityRef, + metricId, + from, + to, + permissionsFilter, + ); + + expect(permissionUtils.filterAuthorizedMetrics).toHaveBeenCalledWith( + [provider.getMetrics()[0]], + permissionsFilter, + ); + }); + }); + describe('getLatestEntityMetrics with batch providers', () => { it('should return correct per-metric metadata for batch provider metrics', async () => { const batchMetricsList = filecheckBatchMetrics.map(m => ({ diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.ts index a655b3601cd..05c82a8ac4d 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.ts @@ -22,11 +22,17 @@ import { ScorecardEntityHealthSummary, aggregationTypes, AggregatedMetric, + MetricTimeSeriesResponse, + MetricTimeSeriesPoint, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; import type { Entity } from '@backstage/catalog-model'; import { normalizeOwnerRef } from '../utils/normalizeOwnerRef'; import { MetricProvidersRegistry } from '../providers/MetricProvidersRegistry'; -import { NotFoundError, stringifyError } from '@backstage/errors'; +import { + NotAllowedError, + NotFoundError, + stringifyError, +} from '@backstage/errors'; import { AuthService, BackstageCredentials, @@ -159,6 +165,7 @@ export class CatalogMetricService { description: metric.description, type: metric.type, history: metric.history, + defaultVisualization: metric.defaultVisualization, }, ...(isMetricCalcError && { error: @@ -180,6 +187,86 @@ export class CatalogMetricService { ); } + /** + * Get a daily time series for one metric on one catalog entity. + * + * Buckets samples by UTC calendar day and keeps the latest row (`MAX(id)`) per day. + * Calculation failures and null values are excluded from `points`. + * + * @param entityRef - Entity reference in format "kind:namespace/name" + * @param metricId - Metric ID to fetch + * @param from - Inclusive range start + * @param to - Inclusive range end + * @param filter - Permission filter + */ + async getEntityMetricTimeSeries( + entityRef: string, + metricId: string, + from: Date, + to: Date, + filter?: PermissionCriteria< + PermissionCondition + >, + ): Promise { + const entity = await this.catalog.getEntityByRef(entityRef, { + credentials: await this.auth.getOwnServiceCredentials(), + }); + if (!entity) { + throw new NotFoundError(`Entity not found: ${entityRef}`); + } + + const metric = this.registry.getMetric(metricId); + const authorizedMetrics = filterAuthorizedMetrics([metric], filter); + if (authorizedMetrics.length === 0) { + throw new NotAllowedError( + `To view the scorecard metrics, your administrator must grant you the required permission.`, + ); + } + + const rows = await this.database.readEntityMetricValuesInRange( + entityRef, + metricId, + from, + to, + ); + + const latestByUtcDay = new Map(); + for (const row of rows) { + if (row.value === null || isMetricCalculationError(row)) { + continue; + } + const dayKey = new Date(row.timestamp).toISOString().slice(0, 10); + const existing = latestByUtcDay.get(dayKey); + // Postgres may return bigIncrements as strings; compare numerically. + if (!existing || Number(row.id) > Number(existing.id)) { + latestByUtcDay.set(dayKey, row); + } + } + + const points: MetricTimeSeriesPoint[] = Array.from(latestByUtcDay.values()) + .sort( + (a, b) => + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), + ) + .map(row => ({ + value: row.value as NonNullable, + timestamp: new Date(row.timestamp).toISOString(), + })); + + return { + metricId: metric.id, + entityRef, + points, + metadata: { + title: metric.title, + description: metric.description, + type: metric.type, + history: metric.history, + defaultVisualization: metric.defaultVisualization, + }, + }; + } + /** * Get an aggregated metric by status grouped for multiple entities and a single metric ID. * diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts index 3736a2e53b9..33c347955bd 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts @@ -37,6 +37,7 @@ import { DEFAULT_NUMBER_THRESHOLDS, Metric, MetricResult, + MetricTimeSeriesResponse, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; import { CatalogMetricService } from './CatalogMetricService'; import { NotFoundError } from '@backstage/errors'; @@ -505,6 +506,142 @@ describe('createRouter', () => { }); }); + describe('GET /metrics/catalog/:kind/:namespace/:name/time-series', () => { + const mockTimeSeriesResponse: MetricTimeSeriesResponse = { + metricId: 'github.openPRs', + entityRef: 'component:default/my-service', + metadata: { + title: 'GitHub open PRs', + description: 'The number of open pull requests.', + type: 'number', + history: true, + defaultVisualization: 'value', + }, + points: [ + { value: 8, timestamp: '2024-01-01T20:00:00.000Z' }, + { value: 7, timestamp: '2024-01-02T12:00:00.000Z' }, + ], + }; + + const timeSeriesPath = + '/metrics/catalog/component/default/my-service/time-series'; + const validQuery = + 'metricId=github.openPRs&from=2024-01-01T00:00:00.000Z&to=2024-01-31T23:59:59.000Z'; + + beforeEach(() => { + jest + .spyOn(catalogMetricService, 'getEntityMetricTimeSeries') + .mockResolvedValue(mockTimeSeriesResponse); + }); + + it('should return 403 Unauthorized when DENY permissions', async () => { + permissionsMock.authorizeConditional.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + const result = await request(app).get(`${timeSeriesPath}?${validQuery}`); + + expect(result.statusCode).toBe(403); + expect(result.body.error.name).toEqual('NotAllowedError'); + }); + + it('should return time series for a specific entity and metric', async () => { + const response = await request(app).get( + `${timeSeriesPath}?${validQuery}`, + ); + + expect(response.status).toBe(200); + expect( + catalogMetricService.getEntityMetricTimeSeries, + ).toHaveBeenCalledWith( + 'component:default/my-service', + 'github.openPRs', + new Date('2024-01-01T00:00:00.000Z'), + new Date('2024-01-31T23:59:59.000Z'), + undefined, + ); + expect(response.body).toEqual(mockTimeSeriesResponse); + }); + + it('should check entity access before returning time series', async () => { + const checkEntityAccessSpy = jest.spyOn( + permissionUtilsModule, + 'checkEntityAccess', + ); + const response = await request(app).get( + `${timeSeriesPath}?${validQuery}`, + ); + + expect(response.status).toBe(200); + expect(checkEntityAccessSpy).toHaveBeenCalledWith( + 'component:default/my-service', + expect.any(Object), + permissionsMock, + httpAuthMock, + ); + }); + + it('should filter authorized metrics when CONDITIONAL permission', async () => { + permissionsMock.authorizeConditional.mockResolvedValue([ + CONDITIONAL_POLICY_DECISION, + ]); + const response = await request(app).get( + `${timeSeriesPath}?${validQuery}`, + ); + + expect(response.status).toBe(200); + expect( + catalogMetricService.getEntityMetricTimeSeries, + ).toHaveBeenCalledWith( + 'component:default/my-service', + 'github.openPRs', + new Date('2024-01-01T00:00:00.000Z'), + new Date('2024-01-31T23:59:59.000Z'), + { + anyOf: [ + { + rule: 'HAS_METRIC_ID', + resourceType: 'scorecard-metric', + params: { metricIds: ['github.openPRs', 'github.openIssues'] }, + }, + ], + }, + ); + }); + + it('should return 404 NotFoundError when entity is not found', async () => { + jest + .spyOn(catalogMetricService, 'getEntityMetricTimeSeries') + .mockRejectedValue( + new NotFoundError('Entity not found: component:default/non-existent'), + ); + + const response = await request(app).get( + `/metrics/catalog/component/default/non-existent/time-series?${validQuery}`, + ); + + expect(response.status).toBe(404); + expect(response.body.error.name).toBe('NotFoundError'); + expect(response.body.error.message).toContain('Entity not found'); + }); + + it('should return 400 InputError when query parameters are missing', async () => { + const response = await request(app).get(timeSeriesPath); + + expect(response.status).toBe(400); + expect(response.body.error.name).toBe('InputError'); + expect(response.body.error.message).toContain('Invalid query parameters'); + }); + + it('should return 400 InputError when from is after to', async () => { + const response = await request(app).get( + `${timeSeriesPath}?metricId=github.openPRs&from=2024-02-01T00:00:00.000Z&to=2024-01-01T00:00:00.000Z`, + ); + + expect(response.status).toBe(400); + expect(response.body.error.name).toBe('InputError'); + }); + }); + describe('GET /metrics/:metricId/catalog/aggregations', () => { const mockAggregatedMetric: AggregatedMetric = { values: { diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/router.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/router.ts index da79a9fda96..c4b8de4ec30 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/router.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/router.ts @@ -36,6 +36,7 @@ import { } from '../permissions/permissionUtils'; import { stringifyEntityRef } from '@backstage/catalog-model'; import { validateMetricIdsQueryParams } from '../middlewares/validateMetricIdsQueryParams'; +import { validateTimeSeriesQueryParams } from '../middlewares/validateTimeSeriesQueryParams'; import { getEntitiesOwnedByUser } from '../utils/getEntitiesOwnedByUser'; import { parseCommaSeparatedString } from '../utils/parseCommaSeparatedString'; import { AggregatedMetricMapper } from './mappers'; @@ -136,6 +137,34 @@ export async function createRouter({ }, ); + router.get( + '/metrics/catalog/:kind/:namespace/:name/time-series', + validateTimeSeriesQueryParams, + async (req, res) => { + const { metricId, from, to } = req.query; + + const { conditions } = await authorizeConditional( + await httpAuth.credentials(req), + permissions, + scorecardMetricReadPermission, + ); + + const { kind, namespace, name } = req.params; + const entityRef = stringifyEntityRef({ kind, namespace, name }); + + await checkEntityAccess(entityRef, req, permissions, httpAuth); + + const result = await catalogMetricService.getEntityMetricTimeSeries( + entityRef, + metricId as string, + new Date(from as string), + new Date(to as string), + conditions, + ); + res.json(result); + }, + ); + // Deprecated (RFC 8594): use GET /aggregations/:aggregationId instead. router.get( '/metrics/:metricId/catalog/aggregations', diff --git a/workspaces/scorecard/plugins/scorecard-common/package.json b/workspaces/scorecard/plugins/scorecard-common/package.json index fd4c3857b4e..54bab55cd4c 100644 --- a/workspaces/scorecard/plugins/scorecard-common/package.json +++ b/workspaces/scorecard/plugins/scorecard-common/package.json @@ -45,6 +45,7 @@ "homepage": "https://red.ht/rhdh", "bugs": "https://github.com/redhat-developer/rhdh-plugins/issues", "dependencies": { - "@backstage/plugin-permission-common": "^0.9.9" + "@backstage/plugin-permission-common": "^0.9.9", + "@backstage/types": "^1.2.2" } } diff --git a/workspaces/scorecard/plugins/scorecard-common/report.api.md b/workspaces/scorecard/plugins/scorecard-common/report.api.md index 1433e3cfc75..2857bcc05e2 100644 --- a/workspaces/scorecard/plugins/scorecard-common/report.api.md +++ b/workspaces/scorecard/plugins/scorecard-common/report.api.md @@ -3,6 +3,7 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { JsonValue } from '@backstage/types'; import { ResourcePermission } from '@backstage/plugin-permission-common'; // @public (undocumented) @@ -88,6 +89,12 @@ export const aggregationTypes: Readonly<{ count: 'count'; }>; +// @public (undocumented) +export type CollectorConfig = { + id: string; + input?: Record; +}; + // @public export const DEFAULT_NUMBER_THRESHOLDS: ThresholdConfig; @@ -130,8 +137,12 @@ export type Metric = { type: T; thresholds: ThresholdConfig; history?: boolean; + defaultVisualization?: MetricDefaultVisualization; }; +// @public +export type MetricDefaultVisualization = 'value' | 'sparkline'; + // @public (undocumented) export type MetricResult = { id: string; @@ -141,6 +152,7 @@ export type MetricResult = { description: string; type: MetricType; history?: boolean; + defaultVisualization?: MetricDefaultVisualization; }; result: { value: MetricValue | null; @@ -150,6 +162,26 @@ export type MetricResult = { error?: string; }; +// @public +export type MetricTimeSeriesPoint = { + value: MetricValue; + timestamp: string; +}; + +// @public +export type MetricTimeSeriesResponse = { + metricId: string; + entityRef: string; + points: MetricTimeSeriesPoint[]; + metadata: { + title: string; + description: string; + type: MetricType; + history?: boolean; + defaultVisualization?: MetricDefaultVisualization; + }; +}; + // @public (undocumented) export type MetricType = 'number' | 'boolean'; diff --git a/workspaces/scorecard/plugins/scorecard-common/src/types/Metric.ts b/workspaces/scorecard/plugins/scorecard-common/src/types/Metric.ts index 48eb0341501..4973d56c4bd 100644 --- a/workspaces/scorecard/plugins/scorecard-common/src/types/Metric.ts +++ b/workspaces/scorecard/plugins/scorecard-common/src/types/Metric.ts @@ -21,6 +21,14 @@ import { ThresholdConfig, ThresholdResult } from './threshold'; */ export type MetricType = 'number' | 'boolean'; +/** + * Default visualization for a metric on the entity scorecard. + * Omit / undefined means `'value'`. + * + * @public + */ +export type MetricDefaultVisualization = 'value' | 'sparkline'; + /** * @public */ @@ -40,6 +48,7 @@ export type Metric = { type: T; thresholds: ThresholdConfig; history?: boolean; + defaultVisualization?: MetricDefaultVisualization; }; /** @@ -53,6 +62,7 @@ export type MetricResult = { description: string; type: MetricType; history?: boolean; + defaultVisualization?: MetricDefaultVisualization; }; result: { value: MetricValue | null; @@ -112,3 +122,30 @@ export type EntityMetricDetailResponse = { }; entityHealth: ScorecardEntityHealthSummary; }; + +/** + * A single sample in a metric time series (latest successful value for a UTC day). + * @public + */ +export type MetricTimeSeriesPoint = { + value: MetricValue; + /** ISO-8601 timestamp of the chosen sample */ + timestamp: string; +}; + +/** + * Daily time-series response for one metric on one catalog entity. + * @public + */ +export type MetricTimeSeriesResponse = { + metricId: string; + entityRef: string; + points: MetricTimeSeriesPoint[]; + metadata: { + title: string; + description: string; + type: MetricType; + history?: boolean; + defaultVisualization?: MetricDefaultVisualization; + }; +}; diff --git a/workspaces/scorecard/plugins/scorecard-common/src/types/collector.ts b/workspaces/scorecard/plugins/scorecard-common/src/types/collector.ts new file mode 100644 index 00000000000..3d604215691 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-common/src/types/collector.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. + */ + +import { JsonValue } from '@backstage/types'; + +/** + * @public + */ +export type CollectorConfig = { + id: string; + input?: Record; +}; diff --git a/workspaces/scorecard/plugins/scorecard-common/src/types/index.ts b/workspaces/scorecard/plugins/scorecard-common/src/types/index.ts index 3985f365279..f1b6e93e10e 100644 --- a/workspaces/scorecard/plugins/scorecard-common/src/types/index.ts +++ b/workspaces/scorecard/plugins/scorecard-common/src/types/index.ts @@ -17,3 +17,4 @@ export * from './Metric'; export * from './threshold'; export * from './aggregation'; +export * from './collector'; diff --git a/workspaces/scorecard/plugins/scorecard/report-alpha.api.md b/workspaces/scorecard/plugins/scorecard/report-alpha.api.md index 20f6e779701..a3ea2211369 100644 --- a/workspaces/scorecard/plugins/scorecard/report-alpha.api.md +++ b/workspaces/scorecard/plugins/scorecard/report-alpha.api.md @@ -39,6 +39,14 @@ export const scorecardTranslationRef: TranslationRef< readonly 'errors.noDataFoundMessage': string; readonly 'errors.unsupportedAggregationType': string; readonly 'errors.authenticationErrorMessage': string; + readonly 'metric.dora.deploymentFrequency.title': string; + readonly 'metric.dora.deploymentFrequency.description': string; + readonly 'metric.dora.medianLeadTimeForChanges.title': string; + readonly 'metric.dora.medianLeadTimeForChanges.description': string; + readonly 'metric.dora.changeFailureRate.title': string; + readonly 'metric.dora.changeFailureRate.description': string; + readonly 'metric.dora.meanTimeToRestore.title': string; + readonly 'metric.dora.meanTimeToRestore.description': string; readonly 'metric.github.openPRs.title': string; readonly 'metric.github.openPRs.description': string; readonly 'metric.jira.openIssues.title': string; @@ -85,6 +93,9 @@ export const scorecardTranslationRef: TranslationRef< readonly 'thresholds.success': string; readonly 'thresholds.warning': string; readonly 'thresholds.error': string; + readonly 'thresholds.elite': string; + readonly 'thresholds.medium': string; + readonly 'thresholds.low': string; readonly 'thresholds.exist': string; readonly 'thresholds.missing': string; readonly 'thresholds.noEntities': string; diff --git a/workspaces/scorecard/plugins/scorecard/report-legacy.api.md b/workspaces/scorecard/plugins/scorecard/report-legacy.api.md index e205c4e6c43..77eb6f474b1 100644 --- a/workspaces/scorecard/plugins/scorecard/report-legacy.api.md +++ b/workspaces/scorecard/plugins/scorecard/report-legacy.api.md @@ -65,6 +65,14 @@ export const scorecardTranslationRef: TranslationRef< readonly 'errors.noDataFoundMessage': string; readonly 'errors.unsupportedAggregationType': string; readonly 'errors.authenticationErrorMessage': string; + readonly 'metric.dora.deploymentFrequency.title': string; + readonly 'metric.dora.deploymentFrequency.description': string; + readonly 'metric.dora.medianLeadTimeForChanges.title': string; + readonly 'metric.dora.medianLeadTimeForChanges.description': string; + readonly 'metric.dora.changeFailureRate.title': string; + readonly 'metric.dora.changeFailureRate.description': string; + readonly 'metric.dora.meanTimeToRestore.title': string; + readonly 'metric.dora.meanTimeToRestore.description': string; readonly 'metric.github.openPRs.title': string; readonly 'metric.github.openPRs.description': string; readonly 'metric.jira.openIssues.title': string; @@ -111,6 +119,9 @@ export const scorecardTranslationRef: TranslationRef< readonly 'thresholds.success': string; readonly 'thresholds.warning': string; readonly 'thresholds.error': string; + readonly 'thresholds.elite': string; + readonly 'thresholds.medium': string; + readonly 'thresholds.low': string; readonly 'thresholds.exist': string; readonly 'thresholds.missing': string; readonly 'thresholds.noEntities': string; diff --git a/workspaces/scorecard/plugins/scorecard/report.api.md b/workspaces/scorecard/plugins/scorecard/report.api.md index 552da2a5092..2749db1aa86 100644 --- a/workspaces/scorecard/plugins/scorecard/report.api.md +++ b/workspaces/scorecard/plugins/scorecard/report.api.md @@ -380,6 +380,14 @@ export const scorecardTranslationRef: TranslationRef< readonly 'errors.noDataFoundMessage': string; readonly 'errors.unsupportedAggregationType': string; readonly 'errors.authenticationErrorMessage': string; + readonly 'metric.dora.deploymentFrequency.title': string; + readonly 'metric.dora.deploymentFrequency.description': string; + readonly 'metric.dora.medianLeadTimeForChanges.title': string; + readonly 'metric.dora.medianLeadTimeForChanges.description': string; + readonly 'metric.dora.changeFailureRate.title': string; + readonly 'metric.dora.changeFailureRate.description': string; + readonly 'metric.dora.meanTimeToRestore.title': string; + readonly 'metric.dora.meanTimeToRestore.description': string; readonly 'metric.github.openPRs.title': string; readonly 'metric.github.openPRs.description': string; readonly 'metric.jira.openIssues.title': string; @@ -426,6 +434,9 @@ export const scorecardTranslationRef: TranslationRef< readonly 'thresholds.success': string; readonly 'thresholds.warning': string; readonly 'thresholds.error': string; + readonly 'thresholds.elite': string; + readonly 'thresholds.medium': string; + readonly 'thresholds.low': string; readonly 'thresholds.exist': string; readonly 'thresholds.missing': string; readonly 'thresholds.noEntities': string; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/Common/CardWrapper.tsx b/workspaces/scorecard/plugins/scorecard/src/components/Common/CardWrapper.tsx index 248c2133c04..029deefdfe4 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/Common/CardWrapper.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/Common/CardWrapper.tsx @@ -96,21 +96,23 @@ export const CardWrapper = ({ > {description && ( - - {description} - + + + {description} + + )} diff --git a/workspaces/scorecard/plugins/scorecard/src/components/Common/__tests__/CardWrapper.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/Common/__tests__/CardWrapper.test.tsx index ea60b800310..111c84948f5 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/Common/__tests__/CardWrapper.test.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/Common/__tests__/CardWrapper.test.tsx @@ -15,6 +15,7 @@ */ import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { CardWrapper } from '../CardWrapper'; @@ -38,4 +39,20 @@ describe('CardWrapper Component', () => { ); expect(screen.getByRole('separator')).toBeInTheDocument(); }); + + it('should show full description in tooltip on hover', async () => { + const user = userEvent.setup(); + const description = + 'This is a long scorecard description that should appear in full on hover'; + + render( + +

Test Content

+
, + ); + + await user.hover(screen.getByText(description)); + + expect(await screen.findByRole('tooltip')).toHaveTextContent(description); + }); }); diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/de.ts b/workspaces/scorecard/plugins/scorecard/src/translations/de.ts index d3afe8451e6..ee861b5245b 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/de.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/de.ts @@ -96,6 +96,20 @@ const scorecardTranslationDe = createTranslationMessages({ 'Gesamtpunktzahl {{total}}', 'metric.drillDownCalculationFailures': 'Bei der Berechnung dieser Kennzahl ist ein oder mehrere Fehler aufgetreten.', + 'metric.dora.deploymentFrequency.description': + 'Erfasst, wie oft Code in den letzten 30 Tagen erfolgreich in die Produktion bereitgestellt wurde. Elite-Performer stellen bei Bedarf bereit (mehrmals täglich).', + 'metric.dora.deploymentFrequency.title': 'DORA - Bereitstellungshäufigkeit', + 'metric.dora.medianLeadTimeForChanges.description': + 'Misst die Zeit vom Code-Commit bis zur Produktionsbereitstellung über die letzten 30 Tage. Elite-Performer haben eine Vorlaufzeit von weniger als 24 Stunden.', + 'metric.dora.medianLeadTimeForChanges.title': + 'DORA - Mittlere Vorlaufzeit für Änderungen', + 'metric.dora.changeFailureRate.description': + 'Überwacht den Prozentsatz der Bereitstellungen, die in den letzten 30 Tagen einen Fehler in der Produktion verursachen. Elite-Performer halten die Änderungsfehlerrate unter 5 %.', + 'metric.dora.changeFailureRate.title': 'DORA - Änderungsfehlerrate', + 'metric.dora.meanTimeToRestore.description': + 'Erfasst die durchschnittliche Zeit zur Wiederherstellung des Dienstes nach einem Vorfall über die letzten 30 Tage. Elite-Performer stellen den Dienst in weniger als einer Stunde wieder her.', + 'metric.dora.meanTimeToRestore.title': + 'DORA - Mittlere Zeit bis zur Wiederherstellung', 'metric.filecheck.description': 'Prüft, ob die Datei {{name}} im Repository existiert.', 'metric.filecheck.title': 'Dateiprüfung: {{name}}', @@ -167,10 +181,13 @@ const scorecardTranslationDe = createTranslationMessages({ 'permissionRequired.description': 'Um das Scorecard-Plugin anzuzeigen, wenden Sie sich an Ihren Administrator, um die Berechtigung {{permission}} zu erteilen.', 'permissionRequired.title': 'Fehlende Berechtigung', + 'thresholds.elite': 'Elite', 'thresholds.entities_one': '{{count}} Entität', 'thresholds.entities_other': '{{count}} Entitäten', 'thresholds.error': 'Fehler', 'thresholds.exist': 'Existieren', + 'thresholds.low': 'Niedrig', + 'thresholds.medium': 'Mittel', 'thresholds.missing': 'Fehlen', 'thresholds.noEntities': 'Keine Entitäten im Zustand {{category}}', 'thresholds.success': 'Erfolg', diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/es.ts b/workspaces/scorecard/plugins/scorecard/src/translations/es.ts index 9d753de017e..3cf7d5cd459 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/es.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/es.ts @@ -97,6 +97,21 @@ const scorecardTranslationEs = createTranslationMessages({ 'Puntuación total {{total}}', 'metric.drillDownCalculationFailures': 'No se pudieron validar una o más entidades cuando se calculó esta métrica.', + 'metric.dora.deploymentFrequency.description': + 'Realiza un seguimiento de la frecuencia con la que el código se implementa correctamente en producción durante los últimos 30 días. Los mejores equipos implementan bajo demanda (varias veces al día).', + 'metric.dora.deploymentFrequency.title': + 'DORA - Frecuencia de implementación', + 'metric.dora.medianLeadTimeForChanges.description': + 'Mide el tiempo desde el commit del código hasta la implementación en producción durante los últimos 30 días. Los mejores equipos tienen un tiempo de entrega inferior a 24 horas.', + 'metric.dora.medianLeadTimeForChanges.title': + 'DORA - Tiempo medio de entrega de cambios', + 'metric.dora.changeFailureRate.description': + 'Supervisa el porcentaje de implementaciones que provocan un fallo en producción durante los últimos 30 días. Los mejores equipos mantienen una tasa de fallos de cambio inferior al 5 %.', + 'metric.dora.changeFailureRate.title': 'DORA - Tasa de fallos de cambio', + 'metric.dora.meanTimeToRestore.description': + 'Realiza un seguimiento del tiempo medio para restaurar el servicio tras un incidente durante los últimos 30 días. Los mejores equipos restauran el servicio en menos de una hora.', + 'metric.dora.meanTimeToRestore.title': + 'DORA - Tiempo medio de restauración', 'metric.filecheck.description': 'Comprueba si el archivo {{name}} existe en el repositorio.', 'metric.filecheck.title': 'Verificación de archivo: {{name}}', @@ -173,10 +188,13 @@ const scorecardTranslationEs = createTranslationMessages({ 'permissionRequired.description': 'Para ver el complemento de tarjetas de puntuación, comuníquese con su administrador para que le otorgue el permiso {{permission}}.', 'permissionRequired.title': 'Permiso faltante', + 'thresholds.elite': 'Élite', 'thresholds.entities_one': '{{count}} entidad', 'thresholds.entities_other': '{{count}} entidades', 'thresholds.error': 'Error', 'thresholds.exist': 'Existente', + 'thresholds.low': 'Bajo', + 'thresholds.medium': 'Medio', 'thresholds.missing': 'Faltante', 'thresholds.noEntities': 'No hay entidades en el estado {{category}}', 'thresholds.success': 'Éxito', diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts b/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts index 37c28b407e4..b9c9ded13f0 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts @@ -96,6 +96,21 @@ const scorecardTranslationFr = createTranslationMessages({ 'metric.weightedStatusScoreLegendTooltipRowTotal': 'Score total {{total}}', 'metric.drillDownCalculationFailures': 'Une ou plusieurs entités ont rencontré une erreur lors du calcul de cette métrique.', + 'metric.dora.deploymentFrequency.description': + 'Suit la fréquence à laquelle le code est déployé avec succès en production au cours des 30 derniers jours. Les meilleurs performeurs déploient à la demande (plusieurs fois par jour).', + 'metric.dora.deploymentFrequency.title': 'DORA - Fréquence de déploiement', + 'metric.dora.medianLeadTimeForChanges.description': + 'Mesure le temps entre le commit du code et le déploiement en production au cours des 30 derniers jours. Les meilleurs performeurs ont un délai de livraison inférieur à 24 heures.', + 'metric.dora.medianLeadTimeForChanges.title': + 'DORA - Délai médian de livraison des changements', + 'metric.dora.changeFailureRate.description': + 'Surveille le pourcentage de déploiements qui provoquent une défaillance en production au cours des 30 derniers jours. Les meilleurs performeurs maintiennent un taux de défaillance des changements inférieur à 5 %.', + 'metric.dora.changeFailureRate.title': + 'DORA - Taux de défaillance des changements', + 'metric.dora.meanTimeToRestore.description': + "Suit le temps moyen de rétablissement du service après un incident au cours des 30 derniers jours. Les meilleurs performeurs rétablissent le service en moins d'une heure.", + 'metric.dora.meanTimeToRestore.title': + 'DORA - Temps moyen de rétablissement', 'metric.filecheck.description': 'Vérifie si le fichier {{name}} existe dans le référentiel.', 'metric.filecheck.title': 'Vérification du fichier : {{name}}', @@ -170,10 +185,13 @@ const scorecardTranslationFr = createTranslationMessages({ 'permissionRequired.description': "Pour afficher le plugin Scorecard, contactez votre administrateur pour lui accorder l'autorisation {{permission}}.", 'permissionRequired.title': 'Autorisation manquante', + 'thresholds.elite': 'Élite', 'thresholds.entities_one': 'entité {{count}}', 'thresholds.entities_other': '{{count}} entités', 'thresholds.error': 'Erreur', 'thresholds.exist': 'Exister', + 'thresholds.low': 'Faible', + 'thresholds.medium': 'Moyen', 'thresholds.missing': 'Manquant', 'thresholds.noEntities': "Aucune entité dans l'état {{category}}", 'thresholds.success': 'Succès', diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/it.ts b/workspaces/scorecard/plugins/scorecard/src/translations/it.ts index 5c5607c2efb..57256b04b20 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/it.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/it.ts @@ -98,6 +98,20 @@ const scorecardTranslationIt = createTranslationMessages({ 'Punteggio totale {{total}}', 'metric.drillDownCalculationFailures': 'Si è verificato un errore durante il calcolo di questa metrica da parte di una o più entità.', + 'metric.dora.deploymentFrequency.description': + 'Monitora la frequenza con cui il codice viene distribuito correttamente in produzione negli ultimi 30 giorni. I team di elite effettuano il deployment on demand (più volte al giorno).', + 'metric.dora.deploymentFrequency.title': 'DORA - Frequenza di deployment', + 'metric.dora.medianLeadTimeForChanges.description': + 'Misura il tempo dal commit del codice al deployment in produzione negli ultimi 30 giorni. I team di elite hanno un tempo di consegna inferiore a 24 ore.', + 'metric.dora.medianLeadTimeForChanges.title': + 'DORA - Tempo mediano di consegna delle modifiche', + 'metric.dora.changeFailureRate.description': + 'Monitora la percentuale di deployment che causano un errore in produzione negli ultimi 30 giorni. I team di elite mantengono un tasso di fallimento delle modifiche inferiore al 5%.', + 'metric.dora.changeFailureRate.title': + 'DORA - Tasso di fallimento delle modifiche', + 'metric.dora.meanTimeToRestore.description': + "Monitora il tempo medio per ripristinare il servizio dopo un incidente negli ultimi 30 giorni. I team di elite ripristinano il servizio in meno di un'ora.", + 'metric.dora.meanTimeToRestore.title': 'DORA - Tempo medio di ripristino', 'metric.filecheck.description': 'Verifica se il file {{name}} esiste nel repository.', 'metric.filecheck.title': 'Verifica del file: {{name}}', @@ -173,10 +187,13 @@ const scorecardTranslationIt = createTranslationMessages({ 'permissionRequired.description': "Per visualizzare il plugin Scorecard, contatta il tuo amministratore per concedere l'autorizzazione {{permission}}.", 'permissionRequired.title': 'Autorizzazione mancante', + 'thresholds.elite': 'Elite', 'thresholds.entities_one': '{{count}} entità', 'thresholds.entities_other': '{{count}} entità', 'thresholds.error': 'Errore', 'thresholds.exist': 'Esiste', + 'thresholds.low': 'Basso', + 'thresholds.medium': 'Medio', 'thresholds.missing': 'Mancante', 'thresholds.noEntities': 'Nessuna entità nello stato {{category}}', 'thresholds.success': 'Successo', diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts b/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts index 0ac674e9a88..f70a7257834 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts @@ -95,6 +95,19 @@ const scorecardTranslationJa = createTranslationMessages({ 'metric.weightedStatusScoreLegendTooltipRowTotal': '合計スコア {{total}}', 'metric.drillDownCalculationFailures': 'このメトリクスの計算中に 1 つ以上のエンティティーが失敗しました。', + 'metric.dora.deploymentFrequency.description': + '過去 30 日間にコードが本番環境に正常にデプロイされた頻度を追跡します。エリートパフォーマーはオンデマンドでデプロイします (1 日に複数回)。', + 'metric.dora.deploymentFrequency.title': 'DORA - デプロイ頻度', + 'metric.dora.medianLeadTimeForChanges.description': + '過去 30 日間におけるコードコミットから本番デプロイまでの時間を測定します。エリートパフォーマーのリードタイムは 24 時間未満です。', + 'metric.dora.medianLeadTimeForChanges.title': + 'DORA - 変更のリードタイム中央値', + 'metric.dora.changeFailureRate.description': + '過去 30 日間に本番環境での障害を引き起こしたデプロイの割合を監視します。エリートパフォーマーは変更失敗率を 5% 未満に維持します。', + 'metric.dora.changeFailureRate.title': 'DORA - 変更失敗率', + 'metric.dora.meanTimeToRestore.description': + '過去 30 日間におけるインシデント後のサービス復旧までの平均時間を追跡します。エリートパフォーマーは 1 時間未満でサービスを復旧します。', + 'metric.dora.meanTimeToRestore.title': 'DORA - 平均復旧時間', 'metric.filecheck.description': 'リポジトリー内に {{name}} ファイルが存在するかどうかを確認します。', 'metric.filecheck.title': 'ファイルチェック: {{name}}', @@ -167,10 +180,13 @@ const scorecardTranslationJa = createTranslationMessages({ 'permissionRequired.description': 'スコアカードプラグインを表示するには、管理者に連絡して {{permission}} 権限を付与してもらうよう依頼してください。', 'permissionRequired.title': '権限がありません', + 'thresholds.elite': 'エリート', 'thresholds.entities_one': '{{count}} 個のエンティティー', 'thresholds.entities_other': '{{count}} 個のエンティティー', 'thresholds.error': 'エラー', 'thresholds.exist': '存在する', + 'thresholds.low': '低', + 'thresholds.medium': '中', 'thresholds.missing': 'なし', 'thresholds.noEntities': '{{category}} 状態のエンティティーはありません', 'thresholds.success': '成功', diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts b/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts index 050572169d3..061934eeada 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts @@ -81,6 +81,26 @@ export const scorecardMessages = { // Metric translations metric: { + 'dora.deploymentFrequency': { + title: 'DORA - Deployment Frequency', + description: + 'Tracks how often code is successfully deployed to production over the past 30 days. Elite performers deploy on demand (multiple times per day).', + }, + 'dora.medianLeadTimeForChanges': { + title: 'DORA - Median Lead Time for Changes', + description: + 'Measures the time from code commit to production deployment over the past 30 days. Elite performers have a lead time of less than 24 hours', + }, + 'dora.changeFailureRate': { + title: 'DORA - Change Failure Rate', + description: + 'Monitors the percentage of deployments that cause a failure in production over the past 30 days. Elite performers maintain a change failure rate below 5%.', + }, + 'dora.meanTimeToRestore': { + title: 'DORA - Mean Time to Restore', + description: + 'Tracks the average time to restore service after an incident over the past 30 days. Elite performers restore service in under one hour.', + }, 'github.openPRs': { title: 'GitHub open PRs', description: @@ -171,6 +191,9 @@ export const scorecardMessages = { success: 'Success', warning: 'Warning', error: 'Error', + elite: 'Elite', + medium: 'Medium', + low: 'Low', exist: 'Exist', missing: 'Missing', noEntities: 'No entities in {{category}} state', diff --git a/workspaces/scorecard/yarn.lock b/workspaces/scorecard/yarn.lock index dac5829eb8f..cf4663bf58e 100644 --- a/workspaces/scorecard/yarn.lock +++ b/workspaces/scorecard/yarn.lock @@ -8769,18 +8769,18 @@ __metadata: languageName: node linkType: hard -"@octokit/core@npm:^5.0.0": - version: 5.2.0 - resolution: "@octokit/core@npm:5.2.0" +"@octokit/core@npm:^5.0.0, @octokit/core@npm:^5.0.2": + version: 5.2.2 + resolution: "@octokit/core@npm:5.2.2" dependencies: "@octokit/auth-token": "npm:^4.0.0" "@octokit/graphql": "npm:^7.1.0" - "@octokit/request": "npm:^8.3.1" - "@octokit/request-error": "npm:^5.1.0" + "@octokit/request": "npm:^8.4.1" + "@octokit/request-error": "npm:^5.1.1" "@octokit/types": "npm:^13.0.0" before-after-hook: "npm:^2.2.0" universal-user-agent: "npm:^6.0.0" - checksum: 10c0/9dc5cf55b335da382f340ef74c8009c06a1f7157b0530d3ff6cacf179887811352dcd405448e37849d73f17b28970b7817995be2260ce902dad52b91905542f0 + checksum: 10c0/b4484d85552303b839613e2133dcd064fa06a7c10fe0ebd11ba8f67cb8e3384e48983c589f4d1dc0fa3754857784e3d90ff4eab9782e118baf13ddd1b834957c languageName: node linkType: hard @@ -8925,10 +8925,10 @@ __metadata: languageName: node linkType: hard -"@octokit/openapi-types@npm:^23.0.1": - version: 23.0.1 - resolution: "@octokit/openapi-types@npm:23.0.1" - checksum: 10c0/ab734ceb26343d9f051a59503b8cb5bdc7fec9ca044b60511b227179bec73141dd9144a6b2d68bcd737741881b136c1b7d5392da89ae2e35e39acc489e5eb4c1 +"@octokit/openapi-types@npm:^24.2.0": + version: 24.2.0 + resolution: "@octokit/openapi-types@npm:24.2.0" + checksum: 10c0/8f47918b35e9b7f6109be6f7c8fc3a64ad13a48233112b29e92559e64a564b810eb3ebf81b4cd0af1bb2989d27b9b95cca96e841ec4e23a3f68703cefe62fd9e languageName: node linkType: hard @@ -8959,6 +8959,17 @@ __metadata: languageName: node linkType: hard +"@octokit/plugin-paginate-rest@npm:11.4.4-cjs.2": + version: 11.4.4-cjs.2 + resolution: "@octokit/plugin-paginate-rest@npm:11.4.4-cjs.2" + dependencies: + "@octokit/types": "npm:^13.7.0" + peerDependencies: + "@octokit/core": 5 + checksum: 10c0/1d61a63c98a18c171bccdc6cf63ffe279fe852e8bdc9db6647ffcb27f4ea485fdab78fb71b552ed0f2186785cf5264f8ed3f9a8f33061e4697b5f73b097accb1 + languageName: node + linkType: hard + "@octokit/plugin-paginate-rest@npm:^6.1.2": version: 6.1.2 resolution: "@octokit/plugin-paginate-rest@npm:6.1.2" @@ -8991,6 +9002,15 @@ __metadata: languageName: node linkType: hard +"@octokit/plugin-request-log@npm:^4.0.0": + version: 4.0.1 + resolution: "@octokit/plugin-request-log@npm:4.0.1" + peerDependencies: + "@octokit/core": 5 + checksum: 10c0/6f556f86258c5fbff9b1821075dc91137b7499f2ad0fd12391f0876064a6daa88abe1748336b2d483516505771d358aa15cb4bcdabc348a79e3d951fe9726798 + languageName: node + linkType: hard + "@octokit/plugin-rest-endpoint-methods@npm:13.2.2": version: 13.2.2 resolution: "@octokit/plugin-rest-endpoint-methods@npm:13.2.2" @@ -9002,6 +9022,17 @@ __metadata: languageName: node linkType: hard +"@octokit/plugin-rest-endpoint-methods@npm:13.3.2-cjs.1": + version: 13.3.2-cjs.1 + resolution: "@octokit/plugin-rest-endpoint-methods@npm:13.3.2-cjs.1" + dependencies: + "@octokit/types": "npm:^13.8.0" + peerDependencies: + "@octokit/core": ^5 + checksum: 10c0/810fe5cb1861386746bf0218ea969d87c56e553ff339490526483b4b66f53c4b4c6092034bec30c5d453172eb6f33e75b5748ade1b401b76774b5a994e2c10b0 + languageName: node + linkType: hard + "@octokit/plugin-rest-endpoint-methods@npm:^7.1.2": version: 7.2.3 resolution: "@octokit/plugin-rest-endpoint-methods@npm:7.2.3" @@ -9120,6 +9151,18 @@ __metadata: languageName: node linkType: hard +"@octokit/rest@npm:^20.1.1": + version: 20.1.2 + resolution: "@octokit/rest@npm:20.1.2" + dependencies: + "@octokit/core": "npm:^5.0.2" + "@octokit/plugin-paginate-rest": "npm:11.4.4-cjs.2" + "@octokit/plugin-request-log": "npm:^4.0.0" + "@octokit/plugin-rest-endpoint-methods": "npm:13.3.2-cjs.1" + checksum: 10c0/712e08c43c7af37c5c219f95ae289b3ac2646270be4e8a7141fa2aa9340ed8f7134f117c9467e89206c5a9797c49c8d2c039b884d4865bb3bde91bc5adb3c38c + languageName: node + linkType: hard + "@octokit/tsconfig@npm:^1.0.2": version: 1.0.2 resolution: "@octokit/tsconfig@npm:1.0.2" @@ -9145,12 +9188,12 @@ __metadata: languageName: node linkType: hard -"@octokit/types@npm:^13.0.0, @octokit/types@npm:^13.1.0, @octokit/types@npm:^13.5.0": - version: 13.8.0 - resolution: "@octokit/types@npm:13.8.0" +"@octokit/types@npm:^13.0.0, @octokit/types@npm:^13.1.0, @octokit/types@npm:^13.5.0, @octokit/types@npm:^13.7.0, @octokit/types@npm:^13.8.0": + version: 13.10.0 + resolution: "@octokit/types@npm:13.10.0" dependencies: - "@octokit/openapi-types": "npm:^23.0.1" - checksum: 10c0/e08c2fcf10e374f18e4c9fa12a6ada33a40f112d1209012a39f0ce40ae7aa9dcf0598b6007b467f63cc4a97e7b1388d6eed34ddef61494655e08b5a95afaad97 + "@octokit/openapi-types": "npm:^24.2.0" + checksum: 10c0/f66a401b89d653ec28e5c1529abdb7965752db4d9d40fa54c80e900af4c6bf944af6bd0a83f5b4f1eecb72e3d646899dfb27ffcf272ac243552de7e3b97a038d languageName: node linkType: hard @@ -10220,6 +10263,23 @@ __metadata: languageName: unknown linkType: soft +"@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-dora@workspace:^, @red-hat-developer-hub/backstage-plugin-scorecard-backend-module-dora@workspace:plugins/scorecard-backend-module-dora": + version: 0.0.0-use.local + resolution: "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-dora@workspace:plugins/scorecard-backend-module-dora" + dependencies: + "@backstage/backend-plugin-api": "npm:^1.9.2" + "@backstage/backend-test-utils": "npm:^1.11.4" + "@backstage/catalog-client": "npm:^1.16.0" + "@backstage/catalog-model": "npm:^1.9.0" + "@backstage/cli": "npm:^0.36.3" + "@backstage/config": "npm:^1.3.8" + "@backstage/types": "npm:^1.2.2" + "@red-hat-developer-hub/backstage-plugin-scorecard-common": "workspace:^" + "@red-hat-developer-hub/backstage-plugin-scorecard-node": "workspace:^" + zod: "npm:^3.22.4" + languageName: unknown + linkType: soft + "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-filecheck@workspace:^, @red-hat-developer-hub/backstage-plugin-scorecard-backend-module-filecheck@workspace:plugins/scorecard-backend-module-filecheck": version: 0.0.0-use.local resolution: "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-filecheck@workspace:plugins/scorecard-backend-module-filecheck" @@ -10250,8 +10310,10 @@ __metadata: "@backstage/integration": "npm:^2.0.3" "@backstage/plugin-catalog-node": "npm:^2.2.2" "@octokit/graphql": "npm:^9.0.1" + "@octokit/rest": "npm:^20.1.1" "@red-hat-developer-hub/backstage-plugin-scorecard-common": "workspace:^" "@red-hat-developer-hub/backstage-plugin-scorecard-node": "workspace:^" + zod: "npm:^3.22.4" languageName: unknown linkType: soft @@ -10266,8 +10328,10 @@ __metadata: "@backstage/cli": "npm:^0.36.3" "@backstage/config": "npm:^1.3.8" "@backstage/plugin-catalog-node": "npm:^2.2.2" + "@backstage/types": "npm:^1.2.2" "@red-hat-developer-hub/backstage-plugin-scorecard-common": "workspace:^" "@red-hat-developer-hub/backstage-plugin-scorecard-node": "workspace:^" + zod: "npm:^3.22.4" languageName: unknown linkType: soft @@ -10334,6 +10398,7 @@ __metadata: dependencies: "@backstage/cli": "npm:^0.36.3" "@backstage/plugin-permission-common": "npm:^0.9.9" + "@backstage/types": "npm:^1.2.2" languageName: unknown linkType: soft @@ -16141,6 +16206,7 @@ __metadata: "@modelcontextprotocol/sdk": "npm:^1.25.2" "@red-hat-developer-hub/backstage-plugin-scorecard-backend": "workspace:^" "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-dependabot": "workspace:^" + "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-dora": "workspace:^" "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-filecheck": "workspace:^" "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-github": "workspace:^" "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-jira": "workspace:^"