diff --git a/workspaces/scorecard/.changeset/add-gitlab-scorecard-module.md b/workspaces/scorecard/.changeset/add-gitlab-scorecard-module.md new file mode 100644 index 0000000000..7c433963cf --- /dev/null +++ b/workspaces/scorecard/.changeset/add-gitlab-scorecard-module.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-gitlab': minor +--- + +Add new GitLab module for the scorecard plugin with issues, merge requests, pipelines, and jobs metrics. diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/.eslintrc.js b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/README.md b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/README.md new file mode 100644 index 0000000000..32cddcfa98 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/README.md @@ -0,0 +1,106 @@ +# Scorecard Backend Module for GitLab + +This is an extension module to the `backstage-plugin-scorecard-backend` plugin. It provides GitLab-specific metrics for software components registered in the Backstage catalog. + +## 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. + +This module also requires a GitLab integration to be configured in your `app-config.yaml`. It uses Backstage's standard GitLab integration configuration, you can check the [docs](https://backstage.io/docs/integrations/gitlab/locations/#configuration) to see all options. + +## Installation + +To install this backend module: + +```bash +# From your root directory +yarn workspace backend add @red-hat-developer-hub/backstage-plugin-scorecard-backend-module-gitlab +``` + +```ts +// packages/backend/src/index.ts +import { createBackend } from '@backstage/backend-defaults'; + +const backend = createBackend(); + +// Scorecard backend plugin +backend.add( + import('@red-hat-developer-hub/backstage-plugin-scorecard-backend'), +); + +// Install the GitLab module +/* highlight-add-next-line */ +backend.add( + import( + '@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-gitlab' + ), +); + +backend.start(); +``` + +### Entity Annotations + +For the GitLab metrics to work, your catalog entities must have the `gitlab.com/project-slug` annotation: + +```yaml +# catalog-info.yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: my-service + annotations: + # Required: GitLab project slug in format "group/project" + gitlab.com/project-slug: my-group/my-project +spec: + type: service + lifecycle: production + owner: team-a +``` + +## Available Metrics + +| Metric ID | Description | Type | Time Window | +| ----------------------------------- | -------------------------------------------------- | ---------- | ----------- | +| `gitlab.open_issues` | Currently open issues | Number | — | +| `gitlab.opened_issues_7d` | Issues opened in the last 7 days | Number | 7 days | +| `gitlab.closed_issues_7d` | Issues closed in the last 7 days | Number | 7 days | +| `gitlab.open_merge_requests` | Currently open merge requests | Number | — | +| `gitlab.opened_merge_requests_7d` | Merge requests opened in the last 7 days | Number | 7 days | +| `gitlab.closed_merge_requests_7d` | Merge requests closed or merged in the last 7 days | Number | 7 days | +| `gitlab.started_pipelines_7d` | Pipelines started in the last 7 days | Number | 7 days | +| `gitlab.successful_pipelines_7d` | Successful pipelines in the last 7 days | Number | 7 days | +| `gitlab.failed_pipelines_7d` | Failed pipelines in the last 7 days | Number | 7 days | +| `gitlab.pipeline_success_ratio_7d` | Pipeline success ratio over the last 7 days | Number (%) | 7 days | +| `gitlab.pipeline_success_ratio_24h` | Pipeline success ratio over the last 24 hours | Number (%) | 24 hours | +| `gitlab.started_jobs_7d` | Jobs started in the last 7 days | Number | 7 days | +| `gitlab.successful_jobs_7d` | Successful jobs in the last 7 days | Number | 7 days | +| `gitlab.failed_jobs_7d` | Failed jobs in the last 7 days | Number | 7 days | +| `gitlab.job_success_ratio_7d` | Job success ratio over the last 7 days | Number (%) | 7 days | +| `gitlab.job_success_ratio_24h` | Job success ratio over the last 24 hours | Number (%) | 24 hours | + +## Configuration + +### Threshold Configuration + +Thresholds define conditions that determine which category a metric value belongs to (`error`, `warning`, or `success`). You can configure custom thresholds for the GitLab metrics. Check out detailed explanation of [threshold configuration](../scorecard-backend/docs/thresholds.md). + +### Schedule Configuration + +The Scorecard plugin uses Backstage's built-in scheduler service to automatically collect metrics from all registered providers every hour by default. However, this configuration can be changed in the `app-config.yaml` file. Here is an example of how to do that: + +```yaml +scorecard: + plugins: + gitlab: + open_issues: + schedule: + frequency: + cron: '0 6 * * *' + timeout: + minutes: 5 + initialDelay: + seconds: 5 +``` + +The schedule configuration follows Backstage's `SchedulerServiceTaskScheduleDefinitionConfig` [schema](https://github.com/backstage/backstage/blob/master/packages/backend-plugin-api/src/services/definitions/SchedulerService.ts#L157). diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/package.json b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/package.json new file mode 100644 index 0000000000..5358245147 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/package.json @@ -0,0 +1,67 @@ +{ + "name": "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-gitlab", + "version": "0.0.0", + "license": "Apache-2.0", + "description": "The gitlab 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" + }, + "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.8.0", + "@backstage/catalog-client": "^1.14.0", + "@backstage/catalog-model": "^1.7.7", + "@backstage/integration": "^2.0.0", + "@red-hat-developer-hub/backstage-plugin-scorecard-common": "workspace:^", + "@red-hat-developer-hub/backstage-plugin-scorecard-node": "workspace:^" + }, + "devDependencies": { + "@backstage/backend-test-utils": "^1.11.1", + "@backstage/cli": "^0.36.0", + "@backstage/config": "^1.3.6" + }, + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "https://github.com/redhat-developer/rhdh-plugins", + "directory": "workspaces/scorecard/plugins/scorecard-backend-module-gitlab" + }, + "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-gitlab/report.api.md b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/report.api.md new file mode 100644 index 0000000000..479a6b5434 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/report.api.md @@ -0,0 +1,11 @@ +## API Report File for "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-gitlab" + +> 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 scorecardModuleGitlab: BackendFeature; +export default scorecardModuleGitlab; +``` diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/gitlab/GitlabClient.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/gitlab/GitlabClient.test.ts new file mode 100644 index 0000000000..b320e27f2a --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/gitlab/GitlabClient.test.ts @@ -0,0 +1,252 @@ +/* + * 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 { GitlabClient } from './GitlabClient'; + +describe('GitlabClient', () => { + let gitlabClient: GitlabClient; + const projectSlug = 'my-group/my-project'; + const mockFetch = jest.fn(); + + const mockConfig = new ConfigReader({ + integrations: { + gitlab: [ + { + host: 'gitlab.com', + token: 'dummy-token', + apiBaseUrl: 'https://gitlab.com/api/v4', + }, + ], + }, + }); + + beforeEach(() => { + jest.clearAllMocks(); + global.fetch = mockFetch; + gitlabClient = new GitlabClient(mockConfig); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function mockResponse( + totalHeader: string | null, + body: unknown[] = [], + ok = true, + ) { + mockFetch.mockResolvedValue({ + ok, + status: ok ? 200 : 500, + statusText: ok ? 'OK' : 'Internal Server Error', + headers: new Map([['x-total', totalHeader]]), + json: async () => body, + }); + } + + describe('getOpenIssuesCount', () => { + it('should return the count from x-total header', async () => { + mockResponse('42'); + const result = await gitlabClient.getOpenIssuesCount(projectSlug); + + expect(result).toBe(42); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining( + '/projects/my-group%2Fmy-project/issues?state=opened&per_page=1', + ), + expect.objectContaining({ + headers: { 'PRIVATE-TOKEN': 'dummy-token' }, + }), + ); + }); + + it('should throw error on API failure', async () => { + mockResponse(null, [], false); + await expect( + gitlabClient.getOpenIssuesCount(projectSlug), + ).rejects.toThrow('GitLab API error: 500 Internal Server Error'); + }); + }); + + describe('getOpenedIssuesCount', () => { + it('should filter by created_after', async () => { + mockResponse('5'); + const since = new Date('2024-01-01T00:00:00Z'); + const result = await gitlabClient.getOpenedIssuesCount( + projectSlug, + since, + ); + + expect(result).toBe(5); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('created_after=2024-01-01T00%3A00%3A00.000Z'), + expect.any(Object), + ); + }); + }); + + describe('getClosedIssuesCount', () => { + it('should filter by state closed and updated_after', async () => { + mockResponse('3'); + const since = new Date('2024-01-01T00:00:00Z'); + const result = await gitlabClient.getClosedIssuesCount( + projectSlug, + since, + ); + + expect(result).toBe(3); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('state=closed'), + expect.any(Object), + ); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('updated_after='), + expect.any(Object), + ); + }); + }); + + describe('getOpenMergeRequestsCount', () => { + it('should return the count of open merge requests', async () => { + mockResponse('10'); + const result = await gitlabClient.getOpenMergeRequestsCount(projectSlug); + + expect(result).toBe(10); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/merge_requests?state=opened'), + expect.any(Object), + ); + }); + }); + + describe('getClosedMergeRequestsCount', () => { + it('should sum closed and merged counts', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Map([['x-total', '4']]), + json: async () => [], + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Map([['x-total', '6']]), + json: async () => [], + }); + + const since = new Date('2024-01-01T00:00:00Z'); + const result = await gitlabClient.getClosedMergeRequestsCount( + projectSlug, + since, + ); + + expect(result).toBe(10); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + }); + + describe('getPipelinesCount', () => { + it('should return pipeline count with status filter', async () => { + mockResponse('15'); + const since = new Date('2024-01-01T00:00:00Z'); + const result = await gitlabClient.getPipelinesCount( + projectSlug, + since, + 'success', + ); + + expect(result).toBe(15); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('status=success'), + expect.any(Object), + ); + }); + + it('should return pipeline count without status filter', async () => { + mockResponse('25'); + const since = new Date('2024-01-01T00:00:00Z'); + const result = await gitlabClient.getPipelinesCount(projectSlug, since); + + expect(result).toBe(25); + }); + }); + + describe('getJobsCount', () => { + it('should return jobs count filtered by date', async () => { + const now = Date.now(); + const since = new Date(now - 7 * 24 * 60 * 60 * 1000); + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Map([['x-total', null]]), + json: async () => [ + { created_at: new Date(now - 1000).toISOString() }, + { created_at: new Date(now - 2000).toISOString() }, + { created_at: new Date(now - 3000).toISOString() }, + ], + }); + + const result = await gitlabClient.getJobsCount(projectSlug, since, [ + 'success', + ]); + + expect(result).toBe(3); + }); + + it('should filter out jobs older than since date', async () => { + const now = Date.now(); + const since = new Date(now - 1000); + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Map([['x-total', null]]), + json: async () => [ + { created_at: new Date(now - 500).toISOString() }, + { created_at: new Date(now - 5000).toISOString() }, + ], + }); + + const result = await gitlabClient.getJobsCount(projectSlug, since); + + expect(result).toBe(1); + }); + }); + + describe('fallback behavior', () => { + it('should use default gitlab.com integration when no token is configured', () => { + const client = new GitlabClient( + new ConfigReader({ + integrations: { + gitlab: [], + }, + }), + ); + + // gitlab.com always has a default integration (without token) + const result = (client as any).getApiBaseUrl('my-group/my-project'); + expect(result.apiBaseUrl).toBe('https://gitlab.com/api/v4'); + expect(result.token).toBeUndefined(); + }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/gitlab/GitlabClient.ts b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/gitlab/GitlabClient.ts new file mode 100644 index 0000000000..d4efc928b5 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/gitlab/GitlabClient.ts @@ -0,0 +1,243 @@ +/* + * 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 { ScmIntegrations } from '@backstage/integration'; +import type { GitlabProjectSlug } from './types'; + +export class GitlabClient { + private readonly integrations: ScmIntegrations; + + constructor(config: Config) { + this.integrations = ScmIntegrations.fromConfig(config); + } + + private getApiBaseUrl(projectSlug: string): { + apiBaseUrl: string; + token: string | undefined; + } { + // Derive the host from the project slug or default to gitlab.com + const host = 'gitlab.com'; + const url = `https://${host}/${projectSlug}`; + const integration = this.integrations.gitlab.byUrl(url); + + if (!integration) { + throw new Error(`Missing GitLab integration for '${url}'`); + } + + return { + apiBaseUrl: integration.config.apiBaseUrl ?? `https://${host}/api/v4`, + token: integration.config.token, + }; + } + + private async fetchApi( + projectSlug: GitlabProjectSlug, + endpoint: string, + params: Record = {}, + ): Promise { + const { apiBaseUrl, token } = this.getApiBaseUrl(projectSlug); + const encodedProject = encodeURIComponent(projectSlug); + const url = new URL(`${apiBaseUrl}/projects/${encodedProject}${endpoint}`); + + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + + const headers: Record = {}; + if (token) { + headers['PRIVATE-TOKEN'] = token; + } + + return fetch(url.toString(), { headers }); + } + + private async fetchTotalCount( + projectSlug: GitlabProjectSlug, + endpoint: string, + params: Record = {}, + ): Promise { + const response = await this.fetchApi(projectSlug, endpoint, { + ...params, + per_page: '1', + }); + + if (!response.ok) { + throw new Error( + `GitLab API error: ${response.status} ${response.statusText}`, + ); + } + + const total = response.headers.get('x-total'); + if (total !== null) { + return parseInt(total, 10); + } + + // Fallback: count items from response body + const items = (await response.json()) as unknown[]; + return items.length; + } + + async getOpenIssuesCount(projectSlug: GitlabProjectSlug): Promise { + return this.fetchTotalCount(projectSlug, '/issues', { + state: 'opened', + }); + } + + async getOpenedIssuesCount( + projectSlug: GitlabProjectSlug, + since: Date, + ): Promise { + return this.fetchTotalCount(projectSlug, '/issues', { + created_after: since.toISOString(), + }); + } + + async getClosedIssuesCount( + projectSlug: GitlabProjectSlug, + since: Date, + ): Promise { + return this.fetchTotalCount(projectSlug, '/issues', { + state: 'closed', + updated_after: since.toISOString(), + }); + } + + async getOpenMergeRequestsCount( + projectSlug: GitlabProjectSlug, + ): Promise { + return this.fetchTotalCount(projectSlug, '/merge_requests', { + state: 'opened', + }); + } + + async getOpenedMergeRequestsCount( + projectSlug: GitlabProjectSlug, + since: Date, + ): Promise { + return this.fetchTotalCount(projectSlug, '/merge_requests', { + created_after: since.toISOString(), + }); + } + + async getClosedMergeRequestsCount( + projectSlug: GitlabProjectSlug, + since: Date, + ): Promise { + const closedCount = await this.fetchTotalCount( + projectSlug, + '/merge_requests', + { + state: 'closed', + updated_after: since.toISOString(), + }, + ); + const mergedCount = await this.fetchTotalCount( + projectSlug, + '/merge_requests', + { + state: 'merged', + updated_after: since.toISOString(), + }, + ); + return closedCount + mergedCount; + } + + async getPipelinesCount( + projectSlug: GitlabProjectSlug, + since: Date, + status?: string, + ): Promise { + const params: Record = { + updated_after: since.toISOString(), + }; + if (status) { + params.status = status; + } + return this.fetchTotalCount(projectSlug, '/pipelines', params); + } + + async getJobsCount( + projectSlug: GitlabProjectSlug, + since: Date, + scope?: string[], + ): Promise { + const params: Record = {}; + + if (scope && scope.length > 0) { + params['scope[]'] = scope.join(','); + } + + // The jobs API doesn't support date filtering directly, + // so we need to fetch and filter by created_at + const jobs = await this.fetchAllPages(projectSlug, '/jobs', params, since); + return jobs.length; + } + + private async fetchAllPages( + projectSlug: GitlabProjectSlug, + endpoint: string, + params: Record, + since: Date, + ): Promise> { + const allItems: Array<{ created_at: string }> = []; + let page = 1; + const perPage = 100; + let hasMore = true; + + while (hasMore) { + const response = await this.fetchApi(projectSlug, endpoint, { + ...params, + page: String(page), + per_page: String(perPage), + }); + + if (!response.ok) { + throw new Error( + `GitLab API error: ${response.status} ${response.statusText}`, + ); + } + + const items = (await response.json()) as Array<{ created_at: string }>; + if (items.length === 0) { + hasMore = false; + continue; + } + + for (const item of items) { + if (new Date(item.created_at) >= since) { + allItems.push(item); + } + } + + // If the oldest item on this page is before 'since', we've gone far enough + const oldestItem = items[items.length - 1]; + if (new Date(oldestItem.created_at) < since) { + hasMore = false; + continue; + } + + if (items.length < perPage) { + hasMore = false; + continue; + } + + page++; + } + + return allItems; + } +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/gitlab/constants.ts b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/gitlab/constants.ts new file mode 100644 index 0000000000..a8ded5b8fe --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/gitlab/constants.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 GITLAB_PROJECT_ANNOTATION = 'gitlab.com/project-slug'; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/gitlab/types.ts b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/gitlab/types.ts new file mode 100644 index 0000000000..5c6f3fffa4 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/gitlab/types.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. + */ + +export type GitlabProjectSlug = string; + +export type GitlabPipelineStatus = + | 'created' + | 'waiting_for_resource' + | 'preparing' + | 'pending' + | 'running' + | 'success' + | 'failed' + | 'canceled' + | 'skipped' + | 'manual' + | 'scheduled'; + +export type GitlabJobStatus = + | 'created' + | 'pending' + | 'running' + | 'failed' + | 'success' + | 'canceled' + | 'skipped' + | 'manual'; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/gitlab/utils.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/gitlab/utils.test.ts new file mode 100644 index 0000000000..34f28387e8 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/gitlab/utils.test.ts @@ -0,0 +1,82 @@ +/* + * 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 { getProjectSlugFromEntity } from './utils'; + +describe('utils', () => { + const mockEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-component', + }, + }; + + describe('getProjectSlugFromEntity', () => { + it('should extract project slug from gitlab.com/project-slug annotation', () => { + const entity = { + ...mockEntity, + metadata: { + ...mockEntity.metadata, + annotations: { + 'gitlab.com/project-slug': 'my-group/my-project', + }, + }, + }; + + const result = getProjectSlugFromEntity(entity); + expect(result).toBe('my-group/my-project'); + }); + + it('should support nested group paths', () => { + const entity = { + ...mockEntity, + metadata: { + ...mockEntity.metadata, + annotations: { + 'gitlab.com/project-slug': 'group/subgroup/my-project', + }, + }, + }; + + const result = getProjectSlugFromEntity(entity); + expect(result).toBe('group/subgroup/my-project'); + }); + + it.each([ + { + description: 'annotation is missing entirely', + annotations: undefined, + }, + { + description: 'project-slug is missing', + annotations: { + 'other-annotation': 'dummy-value', + }, + }, + ])('should throw error when $description', ({ annotations }) => { + const entity = { + ...mockEntity, + metadata: { ...mockEntity.metadata, annotations }, + }; + + expect(() => getProjectSlugFromEntity(entity as Entity)).toThrow( + "Missing annotation 'gitlab.com/project-slug'", + ); + }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/gitlab/utils.ts b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/gitlab/utils.ts new file mode 100644 index 0000000000..9c6e867ec8 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/gitlab/utils.ts @@ -0,0 +1,30 @@ +/* + * 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, stringifyEntityRef } from '@backstage/catalog-model'; +import { GITLAB_PROJECT_ANNOTATION } from './constants'; + +export const getProjectSlugFromEntity = (entity: Entity): string => { + const projectSlug = entity.metadata.annotations?.[GITLAB_PROJECT_ANNOTATION]; + if (!projectSlug) { + throw new Error( + `Missing annotation '${GITLAB_PROJECT_ANNOTATION}' for entity ${stringifyEntityRef( + entity, + )}`, + ); + } + + return projectSlug; +}; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/index.ts b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/index.ts new file mode 100644 index 0000000000..ebb1bfd241 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * The gitlab backend module for the scorecard plugin. + * + * @packageDocumentation + */ + +export { scorecardModuleGitlab as default } from './module'; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/metricProviders/GitlabIssuesProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/metricProviders/GitlabIssuesProvider.test.ts new file mode 100644 index 0000000000..1fcf6a0039 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/metricProviders/GitlabIssuesProvider.test.ts @@ -0,0 +1,120 @@ +/* + * 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 type { Entity } from '@backstage/catalog-model'; +import { GitlabIssuesProvider } from './GitlabIssuesProvider'; +import { GitlabClient } from '../gitlab/GitlabClient'; +import { DEFAULT_NUMBER_THRESHOLDS } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +jest.mock('../gitlab/GitlabClient'); + +describe('GitlabIssuesProvider', () => { + let provider: GitlabIssuesProvider; + const mockedGitlabClient = GitlabClient as jest.MockedClass< + typeof GitlabClient + >; + const mockedClientInstance = { + getOpenIssuesCount: jest.fn(), + getOpenedIssuesCount: jest.fn(), + getClosedIssuesCount: jest.fn(), + } as any; + mockedGitlabClient.mockImplementation(() => mockedClientInstance); + + const mockEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-component', + annotations: { + 'gitlab.com/project-slug': 'my-group/my-project', + }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + provider = GitlabIssuesProvider.fromConfig(new ConfigReader({})); + }); + + describe('metadata', () => { + it('should return correct provider datasource ID', () => { + expect(provider.getProviderDatasourceId()).toBe('gitlab'); + }); + + it('should return correct provider ID', () => { + expect(provider.getProviderId()).toBe('gitlab.open_issues'); + }); + + it('should return number metric type', () => { + expect(provider.getMetricType()).toBe('number'); + }); + + it('should return default number thresholds', () => { + expect(provider.getMetricThresholds()).toEqual(DEFAULT_NUMBER_THRESHOLDS); + }); + + it('should return catalog filter for gitlab annotation', () => { + const filter = provider.getCatalogFilter(); + expect( + filter['metadata.annotations.gitlab.com/project-slug'], + ).toBeDefined(); + }); + + it('should return three metric IDs', () => { + expect(provider.getMetricIds()).toEqual([ + 'gitlab.open_issues', + 'gitlab.opened_issues_7d', + 'gitlab.closed_issues_7d', + ]); + }); + + it('should return three metrics', () => { + const metrics = provider.getMetrics!(); + expect(metrics).toHaveLength(3); + expect(metrics.map(m => m.id)).toEqual([ + 'gitlab.open_issues', + 'gitlab.opened_issues_7d', + 'gitlab.closed_issues_7d', + ]); + }); + }); + + describe('calculateMetric', () => { + it('should return open issues count', async () => { + mockedClientInstance.getOpenIssuesCount.mockResolvedValue(42); + const result = await provider.calculateMetric(mockEntity); + expect(result).toBe(42); + expect(mockedClientInstance.getOpenIssuesCount).toHaveBeenCalledWith( + 'my-group/my-project', + ); + }); + }); + + describe('calculateMetrics', () => { + it('should return all three issue metrics', async () => { + mockedClientInstance.getOpenIssuesCount.mockResolvedValue(10); + mockedClientInstance.getOpenedIssuesCount.mockResolvedValue(5); + mockedClientInstance.getClosedIssuesCount.mockResolvedValue(3); + + const result = await provider.calculateMetrics!(mockEntity); + + expect(result.get('gitlab.open_issues')).toBe(10); + expect(result.get('gitlab.opened_issues_7d')).toBe(5); + expect(result.get('gitlab.closed_issues_7d')).toBe(3); + }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/metricProviders/GitlabIssuesProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/metricProviders/GitlabIssuesProvider.ts new file mode 100644 index 0000000000..da768ce3fe --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/metricProviders/GitlabIssuesProvider.ts @@ -0,0 +1,133 @@ +/* + * 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 { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; +import { + DEFAULT_NUMBER_THRESHOLDS, + Metric, + ThresholdConfig, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { GitlabClient } from '../gitlab/GitlabClient'; +import { getProjectSlugFromEntity } from '../gitlab/utils'; +import { GITLAB_PROJECT_ANNOTATION } from '../gitlab/constants'; + +const METRIC_IDS = { + OPEN_ISSUES: 'gitlab.open_issues', + OPENED_ISSUES_7D: 'gitlab.opened_issues_7d', + CLOSED_ISSUES_7D: 'gitlab.closed_issues_7d', +} as const; + +export class GitlabIssuesProvider implements MetricProvider<'number'> { + private readonly gitlabClient: GitlabClient; + + private constructor(config: Config) { + this.gitlabClient = new GitlabClient(config); + } + + static fromConfig(config: Config): GitlabIssuesProvider { + return new GitlabIssuesProvider(config); + } + + getProviderDatasourceId(): string { + return 'gitlab'; + } + + getProviderId(): string { + return METRIC_IDS.OPEN_ISSUES; + } + + getMetricType(): 'number' { + return 'number'; + } + + getMetric(): Metric<'number'> { + return { + id: METRIC_IDS.OPEN_ISSUES, + title: 'GitLab open issues', + description: 'Current count of open issues for a given GitLab project.', + type: 'number', + history: true, + }; + } + + getMetricThresholds(): ThresholdConfig { + return DEFAULT_NUMBER_THRESHOLDS; + } + + getCatalogFilter(): Record { + return { + [`metadata.annotations.${GITLAB_PROJECT_ANNOTATION}`]: + CATALOG_FILTER_EXISTS, + }; + } + + getMetricIds(): string[] { + return Object.values(METRIC_IDS); + } + + getMetrics(): Metric<'number'>[] { + return [ + { + id: METRIC_IDS.OPEN_ISSUES, + title: 'GitLab open issues', + description: 'Current count of open issues for a given GitLab project.', + type: 'number', + history: true, + }, + { + id: METRIC_IDS.OPENED_ISSUES_7D, + title: 'GitLab issues opened (7d)', + description: + 'Number of issues opened in the last 7 days for a given GitLab project.', + type: 'number', + history: true, + }, + { + id: METRIC_IDS.CLOSED_ISSUES_7D, + title: 'GitLab issues closed (7d)', + description: + 'Number of issues closed in the last 7 days for a given GitLab project.', + type: 'number', + history: true, + }, + ]; + } + + async calculateMetric(entity: Entity): Promise { + const projectSlug = getProjectSlugFromEntity(entity); + return this.gitlabClient.getOpenIssuesCount(projectSlug); + } + + async calculateMetrics(entity: Entity): Promise> { + const projectSlug = getProjectSlugFromEntity(entity); + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + + const [openIssues, openedIssues, closedIssues] = await Promise.all([ + this.gitlabClient.getOpenIssuesCount(projectSlug), + this.gitlabClient.getOpenedIssuesCount(projectSlug, sevenDaysAgo), + this.gitlabClient.getClosedIssuesCount(projectSlug, sevenDaysAgo), + ]); + + return new Map([ + [METRIC_IDS.OPEN_ISSUES, openIssues], + [METRIC_IDS.OPENED_ISSUES_7D, openedIssues], + [METRIC_IDS.CLOSED_ISSUES_7D, closedIssues], + ]); + } +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/metricProviders/GitlabJobsProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/metricProviders/GitlabJobsProvider.test.ts new file mode 100644 index 0000000000..8ada914ce6 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/metricProviders/GitlabJobsProvider.test.ts @@ -0,0 +1,106 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import type { Entity } from '@backstage/catalog-model'; +import { GitlabJobsProvider } from './GitlabJobsProvider'; +import { GitlabClient } from '../gitlab/GitlabClient'; + +jest.mock('../gitlab/GitlabClient'); + +describe('GitlabJobsProvider', () => { + let provider: GitlabJobsProvider; + const mockedGitlabClient = GitlabClient as jest.MockedClass< + typeof GitlabClient + >; + const mockedClientInstance = { + getJobsCount: jest.fn(), + } as any; + mockedGitlabClient.mockImplementation(() => mockedClientInstance); + + const mockEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-component', + annotations: { + 'gitlab.com/project-slug': 'my-group/my-project', + }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + provider = GitlabJobsProvider.fromConfig(new ConfigReader({})); + }); + + describe('metadata', () => { + it('should return correct provider datasource ID', () => { + expect(provider.getProviderDatasourceId()).toBe('gitlab'); + }); + + it('should return five metric IDs', () => { + expect(provider.getMetricIds()).toEqual([ + 'gitlab.started_jobs_7d', + 'gitlab.successful_jobs_7d', + 'gitlab.failed_jobs_7d', + 'gitlab.job_success_ratio_7d', + 'gitlab.job_success_ratio_24h', + ]); + }); + + it('should return five metrics', () => { + const metrics = provider.getMetrics!(); + expect(metrics).toHaveLength(5); + }); + }); + + describe('calculateMetric', () => { + it('should return started jobs count', async () => { + mockedClientInstance.getJobsCount.mockResolvedValue(200); + const result = await provider.calculateMetric(mockEntity); + expect(result).toBe(200); + }); + }); + + describe('calculateMetrics', () => { + it('should return all job metrics', async () => { + mockedClientInstance.getJobsCount + .mockResolvedValueOnce(300) // started 7d + .mockResolvedValueOnce(250) // successful 7d + .mockResolvedValueOnce(30) // failed 7d + .mockResolvedValueOnce(50) // successful 24h + .mockResolvedValueOnce(10); // failed 24h + + const result = await provider.calculateMetrics!(mockEntity); + + expect(result.get('gitlab.started_jobs_7d')).toBe(300); + expect(result.get('gitlab.successful_jobs_7d')).toBe(250); + expect(result.get('gitlab.failed_jobs_7d')).toBe(30); + expect(result.get('gitlab.job_success_ratio_7d')).toBe(89); + expect(result.get('gitlab.job_success_ratio_24h')).toBe(83); + }); + + it('should return 100% ratio when no jobs exist', async () => { + mockedClientInstance.getJobsCount.mockResolvedValue(0); + + const result = await provider.calculateMetrics!(mockEntity); + + expect(result.get('gitlab.job_success_ratio_7d')).toBe(100); + expect(result.get('gitlab.job_success_ratio_24h')).toBe(100); + }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/metricProviders/GitlabJobsProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/metricProviders/GitlabJobsProvider.ts new file mode 100644 index 0000000000..6495b682c0 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/metricProviders/GitlabJobsProvider.ts @@ -0,0 +1,161 @@ +/* + * 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 { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; +import { + DEFAULT_NUMBER_THRESHOLDS, + Metric, + ThresholdConfig, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { GitlabClient } from '../gitlab/GitlabClient'; +import { getProjectSlugFromEntity } from '../gitlab/utils'; +import { GITLAB_PROJECT_ANNOTATION } from '../gitlab/constants'; +import { calculateRatio } from './GitlabPipelinesProvider'; + +const METRIC_IDS = { + STARTED_7D: 'gitlab.started_jobs_7d', + SUCCESSFUL_7D: 'gitlab.successful_jobs_7d', + FAILED_7D: 'gitlab.failed_jobs_7d', + SUCCESS_RATIO_7D: 'gitlab.job_success_ratio_7d', + SUCCESS_RATIO_24H: 'gitlab.job_success_ratio_24h', +} as const; + +export class GitlabJobsProvider implements MetricProvider<'number'> { + private readonly gitlabClient: GitlabClient; + + private constructor(config: Config) { + this.gitlabClient = new GitlabClient(config); + } + + static fromConfig(config: Config): GitlabJobsProvider { + return new GitlabJobsProvider(config); + } + + getProviderDatasourceId(): string { + return 'gitlab'; + } + + getProviderId(): string { + return METRIC_IDS.STARTED_7D; + } + + getMetricType(): 'number' { + return 'number'; + } + + getMetric(): Metric<'number'> { + return { + id: METRIC_IDS.STARTED_7D, + title: 'GitLab jobs started (7d)', + description: + 'Number of jobs started in the last 7 days for a given GitLab project.', + type: 'number', + history: true, + }; + } + + getMetricThresholds(): ThresholdConfig { + return DEFAULT_NUMBER_THRESHOLDS; + } + + getCatalogFilter(): Record { + return { + [`metadata.annotations.${GITLAB_PROJECT_ANNOTATION}`]: + CATALOG_FILTER_EXISTS, + }; + } + + getMetricIds(): string[] { + return Object.values(METRIC_IDS); + } + + getMetrics(): Metric<'number'>[] { + return [ + { + id: METRIC_IDS.STARTED_7D, + title: 'GitLab jobs started (7d)', + description: + 'Number of jobs started in the last 7 days for a given GitLab project.', + type: 'number', + history: true, + }, + { + id: METRIC_IDS.SUCCESSFUL_7D, + title: 'GitLab successful jobs (7d)', + description: + 'Number of successfully finished jobs in the last 7 days for a given GitLab project.', + type: 'number', + history: true, + }, + { + id: METRIC_IDS.FAILED_7D, + title: 'GitLab failed jobs (7d)', + description: + 'Number of failed jobs in the last 7 days for a given GitLab project.', + type: 'number', + history: true, + }, + { + id: METRIC_IDS.SUCCESS_RATIO_7D, + title: 'GitLab job success ratio (7d)', + description: + 'Ratio of successful vs successful+failed jobs in the last 7 days (percentage). Ignores pending, running, and canceled jobs.', + type: 'number', + history: true, + }, + { + id: METRIC_IDS.SUCCESS_RATIO_24H, + title: 'GitLab job success ratio (24h)', + description: + 'Ratio of successful vs successful+failed jobs in the last 24 hours (percentage). Ignores pending, running, and canceled jobs.', + type: 'number', + history: true, + }, + ]; + } + + async calculateMetric(entity: Entity): Promise { + const projectSlug = getProjectSlugFromEntity(entity); + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + return this.gitlabClient.getJobsCount(projectSlug, sevenDaysAgo); + } + + async calculateMetrics(entity: Entity): Promise> { + const projectSlug = getProjectSlugFromEntity(entity); + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + + const [started7d, successful7d, failed7d, successful24h, failed24h] = + await Promise.all([ + this.gitlabClient.getJobsCount(projectSlug, sevenDaysAgo), + this.gitlabClient.getJobsCount(projectSlug, sevenDaysAgo, ['success']), + this.gitlabClient.getJobsCount(projectSlug, sevenDaysAgo, ['failed']), + this.gitlabClient.getJobsCount(projectSlug, oneDayAgo, ['success']), + this.gitlabClient.getJobsCount(projectSlug, oneDayAgo, ['failed']), + ]); + + return new Map([ + [METRIC_IDS.STARTED_7D, started7d], + [METRIC_IDS.SUCCESSFUL_7D, successful7d], + [METRIC_IDS.FAILED_7D, failed7d], + [METRIC_IDS.SUCCESS_RATIO_7D, calculateRatio(successful7d, failed7d)], + [METRIC_IDS.SUCCESS_RATIO_24H, calculateRatio(successful24h, failed24h)], + ]); + } +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/metricProviders/GitlabMergeRequestsProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/metricProviders/GitlabMergeRequestsProvider.test.ts new file mode 100644 index 0000000000..1df56dea6b --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/metricProviders/GitlabMergeRequestsProvider.test.ts @@ -0,0 +1,120 @@ +/* + * 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 type { Entity } from '@backstage/catalog-model'; +import { GitlabMergeRequestsProvider } from './GitlabMergeRequestsProvider'; +import { GitlabClient } from '../gitlab/GitlabClient'; +import { DEFAULT_NUMBER_THRESHOLDS } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +jest.mock('../gitlab/GitlabClient'); + +describe('GitlabMergeRequestsProvider', () => { + let provider: GitlabMergeRequestsProvider; + const mockedGitlabClient = GitlabClient as jest.MockedClass< + typeof GitlabClient + >; + const mockedClientInstance = { + getOpenMergeRequestsCount: jest.fn(), + getOpenedMergeRequestsCount: jest.fn(), + getClosedMergeRequestsCount: jest.fn(), + } as any; + mockedGitlabClient.mockImplementation(() => mockedClientInstance); + + const mockEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-component', + annotations: { + 'gitlab.com/project-slug': 'my-group/my-project', + }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + provider = GitlabMergeRequestsProvider.fromConfig(new ConfigReader({})); + }); + + describe('metadata', () => { + it('should return correct provider datasource ID', () => { + expect(provider.getProviderDatasourceId()).toBe('gitlab'); + }); + + it('should return correct provider ID', () => { + expect(provider.getProviderId()).toBe('gitlab.open_merge_requests'); + }); + + it('should return number metric type', () => { + expect(provider.getMetricType()).toBe('number'); + }); + + it('should return default number thresholds', () => { + expect(provider.getMetricThresholds()).toEqual(DEFAULT_NUMBER_THRESHOLDS); + }); + + it('should return catalog filter for gitlab annotation', () => { + const filter = provider.getCatalogFilter(); + expect( + filter['metadata.annotations.gitlab.com/project-slug'], + ).toBeDefined(); + }); + + it('should return three metric IDs', () => { + expect(provider.getMetricIds()).toEqual([ + 'gitlab.open_merge_requests', + 'gitlab.opened_merge_requests_7d', + 'gitlab.closed_merge_requests_7d', + ]); + }); + + it('should return three metrics', () => { + const metrics = provider.getMetrics!(); + expect(metrics).toHaveLength(3); + expect(metrics.map(m => m.id)).toEqual([ + 'gitlab.open_merge_requests', + 'gitlab.opened_merge_requests_7d', + 'gitlab.closed_merge_requests_7d', + ]); + }); + }); + + describe('calculateMetric', () => { + it('should return open merge requests count', async () => { + mockedClientInstance.getOpenMergeRequestsCount.mockResolvedValue(7); + const result = await provider.calculateMetric(mockEntity); + expect(result).toBe(7); + expect( + mockedClientInstance.getOpenMergeRequestsCount, + ).toHaveBeenCalledWith('my-group/my-project'); + }); + }); + + describe('calculateMetrics', () => { + it('should return all three merge request metrics', async () => { + mockedClientInstance.getOpenMergeRequestsCount.mockResolvedValue(10); + mockedClientInstance.getOpenedMergeRequestsCount.mockResolvedValue(4); + mockedClientInstance.getClosedMergeRequestsCount.mockResolvedValue(6); + + const result = await provider.calculateMetrics!(mockEntity); + + expect(result.get('gitlab.open_merge_requests')).toBe(10); + expect(result.get('gitlab.opened_merge_requests_7d')).toBe(4); + expect(result.get('gitlab.closed_merge_requests_7d')).toBe(6); + }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/metricProviders/GitlabMergeRequestsProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/metricProviders/GitlabMergeRequestsProvider.ts new file mode 100644 index 0000000000..c287e7e00e --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/metricProviders/GitlabMergeRequestsProvider.ts @@ -0,0 +1,135 @@ +/* + * 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 { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; +import { + DEFAULT_NUMBER_THRESHOLDS, + Metric, + ThresholdConfig, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { GitlabClient } from '../gitlab/GitlabClient'; +import { getProjectSlugFromEntity } from '../gitlab/utils'; +import { GITLAB_PROJECT_ANNOTATION } from '../gitlab/constants'; + +const METRIC_IDS = { + OPEN_MRS: 'gitlab.open_merge_requests', + OPENED_MRS_7D: 'gitlab.opened_merge_requests_7d', + CLOSED_MRS_7D: 'gitlab.closed_merge_requests_7d', +} as const; + +export class GitlabMergeRequestsProvider implements MetricProvider<'number'> { + private readonly gitlabClient: GitlabClient; + + private constructor(config: Config) { + this.gitlabClient = new GitlabClient(config); + } + + static fromConfig(config: Config): GitlabMergeRequestsProvider { + return new GitlabMergeRequestsProvider(config); + } + + getProviderDatasourceId(): string { + return 'gitlab'; + } + + getProviderId(): string { + return METRIC_IDS.OPEN_MRS; + } + + getMetricType(): 'number' { + return 'number'; + } + + getMetric(): Metric<'number'> { + return { + id: METRIC_IDS.OPEN_MRS, + title: 'GitLab open merge requests', + description: + 'Current count of open merge requests for a given GitLab project.', + type: 'number', + history: true, + }; + } + + getMetricThresholds(): ThresholdConfig { + return DEFAULT_NUMBER_THRESHOLDS; + } + + getCatalogFilter(): Record { + return { + [`metadata.annotations.${GITLAB_PROJECT_ANNOTATION}`]: + CATALOG_FILTER_EXISTS, + }; + } + + getMetricIds(): string[] { + return Object.values(METRIC_IDS); + } + + getMetrics(): Metric<'number'>[] { + return [ + { + id: METRIC_IDS.OPEN_MRS, + title: 'GitLab open merge requests', + description: + 'Current count of open merge requests for a given GitLab project.', + type: 'number', + history: true, + }, + { + id: METRIC_IDS.OPENED_MRS_7D, + title: 'GitLab merge requests opened (7d)', + description: + 'Number of merge requests opened in the last 7 days for a given GitLab project.', + type: 'number', + history: true, + }, + { + id: METRIC_IDS.CLOSED_MRS_7D, + title: 'GitLab merge requests closed (7d)', + description: + 'Number of merge requests closed or merged in the last 7 days for a given GitLab project.', + type: 'number', + history: true, + }, + ]; + } + + async calculateMetric(entity: Entity): Promise { + const projectSlug = getProjectSlugFromEntity(entity); + return this.gitlabClient.getOpenMergeRequestsCount(projectSlug); + } + + async calculateMetrics(entity: Entity): Promise> { + const projectSlug = getProjectSlugFromEntity(entity); + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + + const [openMRs, openedMRs, closedMRs] = await Promise.all([ + this.gitlabClient.getOpenMergeRequestsCount(projectSlug), + this.gitlabClient.getOpenedMergeRequestsCount(projectSlug, sevenDaysAgo), + this.gitlabClient.getClosedMergeRequestsCount(projectSlug, sevenDaysAgo), + ]); + + return new Map([ + [METRIC_IDS.OPEN_MRS, openMRs], + [METRIC_IDS.OPENED_MRS_7D, openedMRs], + [METRIC_IDS.CLOSED_MRS_7D, closedMRs], + ]); + } +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/metricProviders/GitlabPipelinesProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/metricProviders/GitlabPipelinesProvider.test.ts new file mode 100644 index 0000000000..192a566376 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/metricProviders/GitlabPipelinesProvider.test.ts @@ -0,0 +1,127 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import type { Entity } from '@backstage/catalog-model'; +import { + GitlabPipelinesProvider, + calculateRatio, +} from './GitlabPipelinesProvider'; +import { GitlabClient } from '../gitlab/GitlabClient'; + +jest.mock('../gitlab/GitlabClient'); + +describe('GitlabPipelinesProvider', () => { + let provider: GitlabPipelinesProvider; + const mockedGitlabClient = GitlabClient as jest.MockedClass< + typeof GitlabClient + >; + const mockedClientInstance = { + getPipelinesCount: jest.fn(), + } as any; + mockedGitlabClient.mockImplementation(() => mockedClientInstance); + + const mockEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-component', + annotations: { + 'gitlab.com/project-slug': 'my-group/my-project', + }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + provider = GitlabPipelinesProvider.fromConfig(new ConfigReader({})); + }); + + describe('metadata', () => { + it('should return correct provider datasource ID', () => { + expect(provider.getProviderDatasourceId()).toBe('gitlab'); + }); + + it('should return five metric IDs', () => { + expect(provider.getMetricIds()).toEqual([ + 'gitlab.started_pipelines_7d', + 'gitlab.successful_pipelines_7d', + 'gitlab.failed_pipelines_7d', + 'gitlab.pipeline_success_ratio_7d', + 'gitlab.pipeline_success_ratio_24h', + ]); + }); + + it('should return five metrics', () => { + const metrics = provider.getMetrics!(); + expect(metrics).toHaveLength(5); + }); + }); + + describe('calculateMetric', () => { + it('should return started pipelines count', async () => { + mockedClientInstance.getPipelinesCount.mockResolvedValue(50); + const result = await provider.calculateMetric(mockEntity); + expect(result).toBe(50); + }); + }); + + describe('calculateMetrics', () => { + it('should return all pipeline metrics', async () => { + mockedClientInstance.getPipelinesCount + .mockResolvedValueOnce(100) // started 7d + .mockResolvedValueOnce(80) // successful 7d + .mockResolvedValueOnce(15) // failed 7d + .mockResolvedValueOnce(20) // successful 24h + .mockResolvedValueOnce(5); // failed 24h + + const result = await provider.calculateMetrics!(mockEntity); + + expect(result.get('gitlab.started_pipelines_7d')).toBe(100); + expect(result.get('gitlab.successful_pipelines_7d')).toBe(80); + expect(result.get('gitlab.failed_pipelines_7d')).toBe(15); + expect(result.get('gitlab.pipeline_success_ratio_7d')).toBe(84); + expect(result.get('gitlab.pipeline_success_ratio_24h')).toBe(80); + }); + + it('should return 100% ratio when no pipelines exist', async () => { + mockedClientInstance.getPipelinesCount.mockResolvedValue(0); + + const result = await provider.calculateMetrics!(mockEntity); + + expect(result.get('gitlab.pipeline_success_ratio_7d')).toBe(100); + expect(result.get('gitlab.pipeline_success_ratio_24h')).toBe(100); + }); + }); +}); + +describe('calculateRatio', () => { + it('should return 100 when no pipelines exist', () => { + expect(calculateRatio(0, 0)).toBe(100); + }); + + it('should calculate correct ratio', () => { + expect(calculateRatio(80, 20)).toBe(80); + expect(calculateRatio(9, 1)).toBe(90); + expect(calculateRatio(0, 10)).toBe(0); + expect(calculateRatio(10, 0)).toBe(100); + }); + + it('should round to nearest integer', () => { + expect(calculateRatio(1, 2)).toBe(33); + expect(calculateRatio(2, 1)).toBe(67); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/metricProviders/GitlabPipelinesProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/metricProviders/GitlabPipelinesProvider.ts new file mode 100644 index 0000000000..7c9420485a --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/metricProviders/GitlabPipelinesProvider.ts @@ -0,0 +1,186 @@ +/* + * 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 { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; +import { + DEFAULT_NUMBER_THRESHOLDS, + Metric, + ThresholdConfig, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { GitlabClient } from '../gitlab/GitlabClient'; +import { getProjectSlugFromEntity } from '../gitlab/utils'; +import { GITLAB_PROJECT_ANNOTATION } from '../gitlab/constants'; + +const METRIC_IDS = { + STARTED_7D: 'gitlab.started_pipelines_7d', + SUCCESSFUL_7D: 'gitlab.successful_pipelines_7d', + FAILED_7D: 'gitlab.failed_pipelines_7d', + SUCCESS_RATIO_7D: 'gitlab.pipeline_success_ratio_7d', + SUCCESS_RATIO_24H: 'gitlab.pipeline_success_ratio_24h', +} as const; + +const PERCENTAGE_THRESHOLDS: ThresholdConfig = { + rules: [ + { key: 'error', expression: '<50' }, + { key: 'warning', expression: '50-80' }, + { key: 'success', expression: '>80' }, + ], +}; + +function calculateRatio(success: number, failed: number): number { + const total = success + failed; + if (total === 0) { + return 100; + } + return Math.round((success / total) * 100); +} + +export class GitlabPipelinesProvider implements MetricProvider<'number'> { + private readonly gitlabClient: GitlabClient; + + private constructor(config: Config) { + this.gitlabClient = new GitlabClient(config); + } + + static fromConfig(config: Config): GitlabPipelinesProvider { + return new GitlabPipelinesProvider(config); + } + + getProviderDatasourceId(): string { + return 'gitlab'; + } + + getProviderId(): string { + return METRIC_IDS.STARTED_7D; + } + + getMetricType(): 'number' { + return 'number'; + } + + getMetric(): Metric<'number'> { + return { + id: METRIC_IDS.STARTED_7D, + title: 'GitLab pipelines started (7d)', + description: + 'Number of pipelines started in the last 7 days for a given GitLab project.', + type: 'number', + history: true, + }; + } + + getMetricThresholds(): ThresholdConfig { + return DEFAULT_NUMBER_THRESHOLDS; + } + + getCatalogFilter(): Record { + return { + [`metadata.annotations.${GITLAB_PROJECT_ANNOTATION}`]: + CATALOG_FILTER_EXISTS, + }; + } + + getMetricIds(): string[] { + return Object.values(METRIC_IDS); + } + + getMetrics(): Metric<'number'>[] { + return [ + { + id: METRIC_IDS.STARTED_7D, + title: 'GitLab pipelines started (7d)', + description: + 'Number of pipelines started in the last 7 days for a given GitLab project.', + type: 'number', + history: true, + }, + { + id: METRIC_IDS.SUCCESSFUL_7D, + title: 'GitLab successful pipelines (7d)', + description: + 'Number of successfully finished pipelines in the last 7 days for a given GitLab project.', + type: 'number', + history: true, + }, + { + id: METRIC_IDS.FAILED_7D, + title: 'GitLab failed pipelines (7d)', + description: + 'Number of failed pipelines in the last 7 days for a given GitLab project.', + type: 'number', + history: true, + }, + { + id: METRIC_IDS.SUCCESS_RATIO_7D, + title: 'GitLab pipeline success ratio (7d)', + description: + 'Ratio of successful vs successful+failed pipelines in the last 7 days (percentage). Ignores pending, running, and canceled pipelines.', + type: 'number', + history: true, + }, + { + id: METRIC_IDS.SUCCESS_RATIO_24H, + title: 'GitLab pipeline success ratio (24h)', + description: + 'Ratio of successful vs successful+failed pipelines in the last 24 hours (percentage). Ignores pending, running, and canceled pipelines.', + type: 'number', + history: true, + }, + ]; + } + + async calculateMetric(entity: Entity): Promise { + const projectSlug = getProjectSlugFromEntity(entity); + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + return this.gitlabClient.getPipelinesCount(projectSlug, sevenDaysAgo); + } + + async calculateMetrics(entity: Entity): Promise> { + const projectSlug = getProjectSlugFromEntity(entity); + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + + const [started7d, successful7d, failed7d, successful24h, failed24h] = + await Promise.all([ + this.gitlabClient.getPipelinesCount(projectSlug, sevenDaysAgo), + this.gitlabClient.getPipelinesCount( + projectSlug, + sevenDaysAgo, + 'success', + ), + this.gitlabClient.getPipelinesCount( + projectSlug, + sevenDaysAgo, + 'failed', + ), + this.gitlabClient.getPipelinesCount(projectSlug, oneDayAgo, 'success'), + this.gitlabClient.getPipelinesCount(projectSlug, oneDayAgo, 'failed'), + ]); + + return new Map([ + [METRIC_IDS.STARTED_7D, started7d], + [METRIC_IDS.SUCCESSFUL_7D, successful7d], + [METRIC_IDS.FAILED_7D, failed7d], + [METRIC_IDS.SUCCESS_RATIO_7D, calculateRatio(successful7d, failed7d)], + [METRIC_IDS.SUCCESS_RATIO_24H, calculateRatio(successful24h, failed24h)], + ]); + } +} + +export { PERCENTAGE_THRESHOLDS, calculateRatio }; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/module.ts b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/module.ts new file mode 100644 index 0000000000..fb9704c1dc --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-gitlab/src/module.ts @@ -0,0 +1,45 @@ +/* + * 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 { scorecardMetricsExtensionPoint } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { GitlabIssuesProvider } from './metricProviders/GitlabIssuesProvider'; +import { GitlabMergeRequestsProvider } from './metricProviders/GitlabMergeRequestsProvider'; +import { GitlabPipelinesProvider } from './metricProviders/GitlabPipelinesProvider'; +import { GitlabJobsProvider } from './metricProviders/GitlabJobsProvider'; + +export const scorecardModuleGitlab = createBackendModule({ + pluginId: 'scorecard', + moduleId: 'gitlab', + register(reg) { + reg.registerInit({ + deps: { + config: coreServices.rootConfig, + metrics: scorecardMetricsExtensionPoint, + }, + async init({ config, metrics }) { + metrics.addMetricProvider( + GitlabIssuesProvider.fromConfig(config), + GitlabMergeRequestsProvider.fromConfig(config), + GitlabPipelinesProvider.fromConfig(config), + GitlabJobsProvider.fromConfig(config), + ); + }, + }); + }, +}); diff --git a/workspaces/scorecard/yarn.lock b/workspaces/scorecard/yarn.lock index 1d401b224f..6f2a69df06 100644 --- a/workspaces/scorecard/yarn.lock +++ b/workspaces/scorecard/yarn.lock @@ -12331,6 +12331,22 @@ __metadata: languageName: unknown linkType: soft +"@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-gitlab@workspace:plugins/scorecard-backend-module-gitlab": + version: 0.0.0-use.local + resolution: "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-gitlab@workspace:plugins/scorecard-backend-module-gitlab" + dependencies: + "@backstage/backend-plugin-api": "npm:^1.8.0" + "@backstage/backend-test-utils": "npm:^1.11.1" + "@backstage/catalog-client": "npm:^1.14.0" + "@backstage/catalog-model": "npm:^1.7.7" + "@backstage/cli": "npm:^0.36.0" + "@backstage/config": "npm:^1.3.6" + "@backstage/integration": "npm:^2.0.0" + "@red-hat-developer-hub/backstage-plugin-scorecard-common": "workspace:^" + "@red-hat-developer-hub/backstage-plugin-scorecard-node": "workspace:^" + languageName: unknown + linkType: soft + "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-jira@workspace:^, @red-hat-developer-hub/backstage-plugin-scorecard-backend-module-jira@workspace:plugins/scorecard-backend-module-jira": version: 0.0.0-use.local resolution: "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-jira@workspace:plugins/scorecard-backend-module-jira"