From fe76a47c6a1a4f33204bef15615987ecff319ffb Mon Sep 17 00:00:00 2001 From: fullsend-code <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:30:25 +0000 Subject: [PATCH 1/2] feat(#3468): add new GitHub metrics to scorecard module Extend the scorecard GitHub module with 13 new metrics across four domains: Issue/PR counts (5 metrics): - Currently open issues - Opened issues in last 7 days - Opened PRs in last 7 days - Closed issues in last 7 days - Closed PRs in last 7 days PR lifecycle timing (3 metrics, batch provider): - Time to first review (average hours) - Time to first approval (average hours) - Time to merge (average hours) All computed from PRs updated in the last 7 days. GitHub Actions (5 metrics, batch provider): - Started workflow runs in last 7 days - Successfully completed runs in last 7 days - Failed runs in last 7 days - Success ratio for 7 days (percentage) - Success ratio for 24 hours (percentage) Non-terminal runs (pending/running/cancelled) are excluded from success/failure counts and ratio calculations. CI pass rate (2 metrics, batch provider): - First-time CI pass rate for 7 days (percentage) - First-time CI pass rate for 24 hours (percentage) Checks CI status on the last commit of the first push to each PR. PRs without CI checks are excluded. New GithubClient methods use GraphQL for issue/PR queries and REST API (fetch) for workflow runs. All providers follow the existing MetricProvider pattern and are registered in the module init. Batch providers use getMetrics/calculateMetrics for efficient multi-metric computation. Closes #3468 --- .../src/github/GithubClient.ts | 238 +++++++++++++++++- .../src/github/types.ts | 22 ++ .../GithubActionsProvider.test.ts | 182 ++++++++++++++ .../metricProviders/GithubActionsProvider.ts | 197 +++++++++++++++ .../GithubCIPassRateProvider.test.ts | 159 ++++++++++++ .../GithubCIPassRateProvider.ts | 148 +++++++++++ .../GithubClosedIssuesProvider.ts | 87 +++++++ .../GithubClosedPRsProvider.ts | 87 +++++++ .../GithubOpenIssuesProvider.test.ts | 82 ++++++ .../GithubOpenIssuesProvider.ts | 79 ++++++ .../GithubOpenedIssuesProvider.ts | 87 +++++++ .../GithubOpenedPRsProvider.ts | 87 +++++++ .../GithubPRLifecycleProvider.test.ts | 159 ++++++++++++ .../GithubPRLifecycleProvider.ts | 191 ++++++++++++++ .../GithubSearchCountProviders.test.ts | 151 +++++++++++ .../src/module.ts | 20 ++ 16 files changed, 1975 insertions(+), 1 deletion(-) create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsProvider.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsProvider.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubCIPassRateProvider.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubCIPassRateProvider.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubClosedIssuesProvider.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubClosedPRsProvider.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenIssuesProvider.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenIssuesProvider.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenedIssuesProvider.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenedPRsProvider.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubPRLifecycleProvider.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubPRLifecycleProvider.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubSearchCountProviders.test.ts 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 20f9fd2f66..ead479b2fb 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 @@ -19,7 +19,12 @@ import { DefaultGithubCredentialsProvider, ScmIntegrations, } from '@backstage/integration'; -import { GithubRepository } from './types'; +import { + GithubRepository, + PullRequestWithReviews, + WorkflowRun, + PullRequestCommitStatus, +} from './types'; export class GithubClient { private readonly integrations: ScmIntegrations; @@ -48,6 +53,31 @@ export class GithubClient { }); } + private async getRestConfig( + url: string, + ): Promise<{ headers: Record; apiBaseUrl: string }> { + const githubIntegration = this.integrations.github.byUrl(url); + if (!githubIntegration) { + throw new Error(`Missing GitHub integration for '${url}'`); + } + + const credentialsProvider = + DefaultGithubCredentialsProvider.fromIntegrations(this.integrations); + + const { headers } = await credentialsProvider.getCredentials({ + url, + }); + + return { + headers: { + ...headers, + Accept: 'application/vnd.github+json', + } as Record, + apiBaseUrl: + githubIntegration.config.apiBaseUrl ?? 'https://api.github.com', + }; + } + async getOpenPullRequestsCount( url: string, repository: GithubRepository, @@ -77,4 +107,210 @@ export class GithubClient { return response.repository.pullRequests.totalCount; } + + async getOpenIssuesCount( + url: string, + repository: GithubRepository, + ): Promise { + const octokit = await this.getOctokitClient(url); + + const query = ` + query getOpenIssuesCount($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + issues(states: OPEN) { + totalCount + } + } + } + `; + + const response = await octokit<{ + repository: { + issues: { + totalCount: number; + }; + }; + }>(query, { + owner: repository.owner, + repo: repository.repo, + }); + + return response.repository.issues.totalCount; + } + + async getSearchCount( + url: string, + repository: GithubRepository, + searchQuery: string, + ): Promise { + const octokit = await this.getOctokitClient(url); + + const fullQuery = `repo:${repository.owner}/${repository.repo} ${searchQuery}`; + + const query = ` + query getSearchCount($q: String!) { + search(query: $q, type: ISSUE) { + issueCount + } + } + `; + + const response = await octokit<{ + search: { + issueCount: number; + }; + }>(query, { + q: fullQuery, + }); + + return response.search.issueCount; + } + + async getPullRequestsWithReviews( + url: string, + repository: GithubRepository, + since: string, + ): Promise { + const octokit = await this.getOctokitClient(url); + + const searchQuery = `repo:${repository.owner}/${repository.repo} is:pr updated:>${since}`; + + const query = ` + query getPRsWithReviews($q: String!) { + search(query: $q, type: ISSUE, first: 100) { + nodes { + ... on PullRequest { + createdAt + mergedAt + reviews(first: 100) { + nodes { + createdAt + state + } + } + } + } + } + } + `; + + const response = await octokit<{ + search: { + nodes: PullRequestWithReviews[]; + }; + }>(query, { + q: searchQuery, + }); + + return response.search.nodes; + } + + async getWorkflowRuns( + url: string, + repository: GithubRepository, + since: string, + ): Promise { + const { headers, apiBaseUrl } = await this.getRestConfig(url); + + const allRuns: WorkflowRun[] = []; + let page = 1; + const perPage = 100; + let hasMore = true; + + while (hasMore) { + const restUrl = `${apiBaseUrl}/repos/${repository.owner}/${repository.repo}/actions/runs?created=>${since}&per_page=${perPage}&page=${page}`; + const response = await fetch(restUrl, { headers }); + + if (!response.ok) { + throw new Error( + `GitHub API error: ${response.status} ${response.statusText}`, + ); + } + + const data = (await response.json()) as { + workflow_runs: WorkflowRun[]; + total_count: number; + }; + + allRuns.push(...data.workflow_runs); + + hasMore = + allRuns.length < data.total_count && + data.workflow_runs.length >= perPage; + page++; + } + + return allRuns; + } + + async getPullRequestsWithCommitStatuses( + url: string, + repository: GithubRepository, + since: string, + ): Promise { + const octokit = await this.getOctokitClient(url); + + const searchQuery = `repo:${repository.owner}/${repository.repo} is:pr created:>${since}`; + + const query = ` + query getPRsWithStatuses($q: String!) { + search(query: $q, type: ISSUE, first: 100) { + nodes { + ... on PullRequest { + createdAt + commits(first: 100) { + nodes { + commit { + committedDate + statusCheckRollup { + state + } + } + } + } + } + } + } + } + `; + + const response = await octokit<{ + search: { + nodes: Array<{ + createdAt: string; + commits: { + nodes: Array<{ + commit: { + committedDate: string; + statusCheckRollup: { + state: string; + } | null; + }; + }>; + }; + }>; + }; + }>(query, { + q: searchQuery, + }); + + return response.search.nodes.map(pr => { + // Find the last commit from the first push (committed on or before PR creation) + const prCreatedAt = new Date(pr.createdAt).getTime(); + const firstPushCommits = pr.commits.nodes.filter( + c => new Date(c.commit.committedDate).getTime() <= prCreatedAt + 60000, // 1 minute tolerance + ); + + const lastFirstPushCommit = + firstPushCommits.length > 0 + ? firstPushCommits[firstPushCommits.length - 1] + : null; + + return { + createdAt: pr.createdAt, + firstPushLastCommitState: + lastFirstPushCommit?.commit.statusCheckRollup?.state ?? null, + }; + }); + } } 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 df73a37e94..aa7bc10fa0 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 @@ -17,3 +17,25 @@ export type GithubRepository = { owner: string; repo: string; }; + +export type PullRequestWithReviews = { + createdAt: string; + mergedAt: string | null; + reviews: { + nodes: Array<{ + createdAt: string; + state: string; + }>; + }; +}; + +export type WorkflowRun = { + status: string; + conclusion: string | null; + created_at: string; +}; + +export type PullRequestCommitStatus = { + createdAt: string; + firstPushLastCommitState: string | null; +}; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsProvider.test.ts new file mode 100644 index 0000000000..bfe95ca09d --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsProvider.test.ts @@ -0,0 +1,182 @@ +/* + * 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 { GithubActionsProvider } from './GithubActionsProvider'; +import { GithubClient } from '../github/GithubClient'; + +jest.mock('@backstage/catalog-model', () => ({ + ...jest.requireActual('@backstage/catalog-model'), + getEntitySourceLocation: jest.fn().mockReturnValue({ + type: 'url', + target: 'https://github.com/org/orgRepo/tree/main/', + }), +})); +jest.mock('../github/GithubClient'); + +const mockEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-component', + annotations: { + 'github.com/project-slug': 'org/orgRepo', + }, + }, +}; + +describe('GithubActionsProvider', () => { + const mockedGithubClient = GithubClient as jest.MockedClass< + typeof GithubClient + >; + const mockedGithubClientInstance = { + getWorkflowRuns: jest.fn(), + } as any; + mockedGithubClient.mockImplementation(() => mockedGithubClientInstance); + + let provider: GithubActionsProvider; + + beforeEach(() => { + jest.clearAllMocks(); + provider = GithubActionsProvider.fromConfig(new ConfigReader({})); + }); + + it('should return all metric IDs', () => { + expect(provider.getMetricIds()).toEqual([ + 'github.actions_started_7d', + 'github.actions_successful_7d', + 'github.actions_failed_7d', + 'github.actions_success_ratio_7d', + 'github.actions_success_ratio_24h', + ]); + }); + + it('should return all metrics', () => { + const metrics = provider.getMetrics!(); + expect(metrics).toHaveLength(5); + }); + + it('should calculate all action metrics', async () => { + const now = new Date(); + const hoursAgo = (h: number) => + new Date(now.getTime() - h * 60 * 60 * 1000).toISOString(); + + mockedGithubClientInstance.getWorkflowRuns.mockResolvedValue([ + // 7d window runs + { + status: 'completed', + conclusion: 'success', + created_at: hoursAgo(48), + }, + { + status: 'completed', + conclusion: 'success', + created_at: hoursAgo(72), + }, + { + status: 'completed', + conclusion: 'failure', + created_at: hoursAgo(96), + }, + { + status: 'in_progress', + conclusion: null, + created_at: hoursAgo(20), + }, + // 24h window runs + { + status: 'completed', + conclusion: 'success', + created_at: hoursAgo(2), + }, + { + status: 'completed', + conclusion: 'failure', + created_at: hoursAgo(5), + }, + ]); + + const results = await provider.calculateMetrics!(mockEntity); + + expect(results.get('github.actions_started_7d')).toBe(6); + expect(results.get('github.actions_successful_7d')).toBe(3); + expect(results.get('github.actions_failed_7d')).toBe(2); + // 7d ratio: 3 success / (3 success + 2 failure) = 60% + expect(results.get('github.actions_success_ratio_7d')).toBe(60); + // 24h ratio: 1 success / (1 success + 1 failure) = 50% + expect(results.get('github.actions_success_ratio_24h')).toBe(50); + }); + + it('should return 100% ratio when no completed runs', async () => { + mockedGithubClientInstance.getWorkflowRuns.mockResolvedValue([ + { + status: 'in_progress', + conclusion: null, + created_at: new Date().toISOString(), + }, + ]); + + const results = await provider.calculateMetrics!(mockEntity); + + expect(results.get('github.actions_success_ratio_7d')).toBe(100); + }); + + it('should handle empty workflow runs', async () => { + mockedGithubClientInstance.getWorkflowRuns.mockResolvedValue([]); + + const results = await provider.calculateMetrics!(mockEntity); + + expect(results.get('github.actions_started_7d')).toBe(0); + expect(results.get('github.actions_successful_7d')).toBe(0); + expect(results.get('github.actions_failed_7d')).toBe(0); + expect(results.get('github.actions_success_ratio_7d')).toBe(100); + expect(results.get('github.actions_success_ratio_24h')).toBe(100); + }); + + it('should exclude non-terminal runs from counts', async () => { + mockedGithubClientInstance.getWorkflowRuns.mockResolvedValue([ + { + status: 'queued', + conclusion: null, + created_at: new Date().toISOString(), + }, + { + status: 'in_progress', + conclusion: null, + created_at: new Date().toISOString(), + }, + { + status: 'completed', + conclusion: 'cancelled', + created_at: new Date().toISOString(), + }, + { + status: 'completed', + conclusion: 'success', + created_at: new Date().toISOString(), + }, + ]); + + const results = await provider.calculateMetrics!(mockEntity); + + expect(results.get('github.actions_started_7d')).toBe(4); + expect(results.get('github.actions_successful_7d')).toBe(1); + expect(results.get('github.actions_failed_7d')).toBe(0); + // Ratio: 1/(1+0) = 100% (cancelled excluded from ratio) + expect(results.get('github.actions_success_ratio_7d')).toBe(100); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsProvider.ts new file mode 100644 index 0000000000..55d3e75f41 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsProvider.ts @@ -0,0 +1,197 @@ +/* + * 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 { getEntitySourceLocation, type Entity } from '@backstage/catalog-model'; +import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; +import { + Metric, + ThresholdConfig, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { GithubClient } from '../github/GithubClient'; +import { getRepositoryInformationFromEntity } from '../github/utils'; +import { WorkflowRun } from '../github/types'; + +const COUNT_THRESHOLDS: ThresholdConfig = { + rules: [ + { key: 'success', expression: '<10' }, + { key: 'warning', expression: '10-50' }, + { key: 'error', expression: '>50' }, + ], +}; + +const RATIO_THRESHOLDS: ThresholdConfig = { + rules: [ + { key: 'success', expression: '>=80' }, + { key: 'warning', expression: '50-79' }, + { key: 'error', expression: '<50' }, + ], +}; + +const METRIC_IDS = { + STARTED: 'github.actions_started_7d', + SUCCESSFUL: 'github.actions_successful_7d', + FAILED: 'github.actions_failed_7d', + SUCCESS_RATIO_7D: 'github.actions_success_ratio_7d', + SUCCESS_RATIO_24H: 'github.actions_success_ratio_24h', +} as const; + +function filterRunsByWindow( + runs: WorkflowRun[], + hoursAgo: number, +): WorkflowRun[] { + const cutoff = new Date(); + cutoff.setHours(cutoff.getHours() - hoursAgo); + return runs.filter(r => new Date(r.created_at) >= cutoff); +} + +function countByConclusion(runs: WorkflowRun[], conclusion: string): number { + return runs.filter( + r => r.status === 'completed' && r.conclusion === conclusion, + ).length; +} + +function computeSuccessRatio(runs: WorkflowRun[]): number { + const completed = runs.filter(r => r.status === 'completed'); + const successful = completed.filter(r => r.conclusion === 'success'); + const failed = completed.filter(r => r.conclusion === 'failure'); + const total = successful.length + failed.length; + if (total === 0) { + return 100; + } + return Math.round((successful.length / total) * 1000) / 10; +} + +export class GithubActionsProvider implements MetricProvider<'number'> { + private readonly githubClient: GithubClient; + + private constructor(config: Config) { + this.githubClient = new GithubClient(config); + } + + getProviderDatasourceId(): string { + return 'github'; + } + + getProviderId() { + return METRIC_IDS.STARTED; + } + + getMetricType(): 'number' { + return 'number'; + } + + getMetric(): Metric<'number'> { + return { + id: METRIC_IDS.STARTED, + title: 'GitHub Actions started (7d)', + description: + 'Number of GitHub Actions workflow runs started in the last 7 days.', + type: this.getMetricType(), + history: true, + }; + } + + getMetricIds(): string[] { + return Object.values(METRIC_IDS); + } + + getMetrics(): Metric<'number'>[] { + return [ + this.getMetric(), + { + id: METRIC_IDS.SUCCESSFUL, + title: 'GitHub Actions successful (7d)', + description: + 'Number of successfully completed GitHub Actions workflow runs in the last 7 days.', + type: this.getMetricType(), + history: true, + }, + { + id: METRIC_IDS.FAILED, + title: 'GitHub Actions failed (7d)', + description: + 'Number of failed GitHub Actions workflow runs in the last 7 days.', + type: this.getMetricType(), + history: true, + }, + { + id: METRIC_IDS.SUCCESS_RATIO_7D, + title: 'GitHub Actions success ratio (7d)', + description: + 'Ratio of successful to successful+failed GitHub Actions workflow runs in the last 7 days (percentage).', + type: this.getMetricType(), + history: true, + }, + { + id: METRIC_IDS.SUCCESS_RATIO_24H, + title: 'GitHub Actions success ratio (24h)', + description: + 'Ratio of successful to successful+failed GitHub Actions workflow runs in the last 24 hours (percentage).', + type: this.getMetricType(), + history: true, + }, + ]; + } + + getMetricThresholds(): ThresholdConfig { + return COUNT_THRESHOLDS; + } + + getCatalogFilter(): Record { + return { + 'metadata.annotations.github.com/project-slug': CATALOG_FILTER_EXISTS, + }; + } + + static fromConfig(config: Config): GithubActionsProvider { + return new GithubActionsProvider(config); + } + + async calculateMetric(entity: Entity): Promise { + const metrics = await this.calculateMetrics(entity); + return metrics.get(METRIC_IDS.STARTED) ?? 0; + } + + async calculateMetrics(entity: Entity): Promise> { + const repository = getRepositoryInformationFromEntity(entity); + const { target } = getEntitySourceLocation(entity); + + const since = new Date(); + since.setDate(since.getDate() - 7); + const sinceStr = since.toISOString().split('T')[0]; + + const runs = await this.githubClient.getWorkflowRuns( + target, + repository, + sinceStr, + ); + + const runs24h = filterRunsByWindow(runs, 24); + + const results = new Map(); + results.set(METRIC_IDS.STARTED, runs.length); + results.set(METRIC_IDS.SUCCESSFUL, countByConclusion(runs, 'success')); + results.set(METRIC_IDS.FAILED, countByConclusion(runs, 'failure')); + results.set(METRIC_IDS.SUCCESS_RATIO_7D, computeSuccessRatio(runs)); + results.set(METRIC_IDS.SUCCESS_RATIO_24H, computeSuccessRatio(runs24h)); + + return results; + } +} + +export { RATIO_THRESHOLDS, COUNT_THRESHOLDS }; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubCIPassRateProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubCIPassRateProvider.test.ts new file mode 100644 index 0000000000..3d9ee46e79 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubCIPassRateProvider.test.ts @@ -0,0 +1,159 @@ +/* + * 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 { GithubCIPassRateProvider } from './GithubCIPassRateProvider'; +import { GithubClient } from '../github/GithubClient'; + +jest.mock('@backstage/catalog-model', () => ({ + ...jest.requireActual('@backstage/catalog-model'), + getEntitySourceLocation: jest.fn().mockReturnValue({ + type: 'url', + target: 'https://github.com/org/orgRepo/tree/main/', + }), +})); +jest.mock('../github/GithubClient'); + +const mockEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-component', + annotations: { + 'github.com/project-slug': 'org/orgRepo', + }, + }, +}; + +describe('GithubCIPassRateProvider', () => { + const mockedGithubClient = GithubClient as jest.MockedClass< + typeof GithubClient + >; + const mockedGithubClientInstance = { + getPullRequestsWithCommitStatuses: jest.fn(), + } as any; + mockedGithubClient.mockImplementation(() => mockedGithubClientInstance); + + let provider: GithubCIPassRateProvider; + + beforeEach(() => { + jest.clearAllMocks(); + provider = GithubCIPassRateProvider.fromConfig(new ConfigReader({})); + }); + + it('should return all metric IDs', () => { + expect(provider.getMetricIds()).toEqual([ + 'github.ci_pass_rate_7d', + 'github.ci_pass_rate_24h', + ]); + }); + + it('should calculate CI pass rates', async () => { + const now = new Date(); + const hoursAgo = (h: number) => + new Date(now.getTime() - h * 60 * 60 * 1000).toISOString(); + + mockedGithubClientInstance.getPullRequestsWithCommitStatuses.mockResolvedValue( + [ + // 7d window, success + { + createdAt: hoursAgo(72), + firstPushLastCommitState: 'SUCCESS', + }, + // 7d window, failure + { + createdAt: hoursAgo(48), + firstPushLastCommitState: 'FAILURE', + }, + // 24h window, success + { + createdAt: hoursAgo(5), + firstPushLastCommitState: 'SUCCESS', + }, + // 24h window, success + { + createdAt: hoursAgo(2), + firstPushLastCommitState: 'SUCCESS', + }, + ], + ); + + const results = await provider.calculateMetrics!(mockEntity); + + // 7d: 3 success out of 4 with CI = 75% + expect(results.get('github.ci_pass_rate_7d')).toBe(75); + // 24h: 2 success out of 2 with CI = 100% + expect(results.get('github.ci_pass_rate_24h')).toBe(100); + }); + + it('should skip PRs without CI checks', async () => { + mockedGithubClientInstance.getPullRequestsWithCommitStatuses.mockResolvedValue( + [ + { + createdAt: new Date().toISOString(), + firstPushLastCommitState: null, + }, + ], + ); + + const results = await provider.calculateMetrics!(mockEntity); + + expect(results.get('github.ci_pass_rate_7d')).toBe(100); + }); + + it('should return 100% when no PRs exist', async () => { + mockedGithubClientInstance.getPullRequestsWithCommitStatuses.mockResolvedValue( + [], + ); + + const results = await provider.calculateMetrics!(mockEntity); + + expect(results.get('github.ci_pass_rate_7d')).toBe(100); + expect(results.get('github.ci_pass_rate_24h')).toBe(100); + }); + + it('should handle mixed CI states', async () => { + const hoursAgo = (h: number) => + new Date(Date.now() - h * 60 * 60 * 1000).toISOString(); + + mockedGithubClientInstance.getPullRequestsWithCommitStatuses.mockResolvedValue( + [ + { + createdAt: hoursAgo(48), + firstPushLastCommitState: 'SUCCESS', + }, + { + createdAt: hoursAgo(48), + firstPushLastCommitState: 'FAILURE', + }, + { + createdAt: hoursAgo(48), + firstPushLastCommitState: 'PENDING', + }, + { + createdAt: hoursAgo(48), + firstPushLastCommitState: null, + }, + ], + ); + + const results = await provider.calculateMetrics!(mockEntity); + + // 1 success out of 3 with CI (null excluded) = 33.3% + expect(results.get('github.ci_pass_rate_7d')).toBe(33.3); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubCIPassRateProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubCIPassRateProvider.ts new file mode 100644 index 0000000000..90f96ee4ea --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubCIPassRateProvider.ts @@ -0,0 +1,148 @@ +/* + * 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 { getEntitySourceLocation, type Entity } from '@backstage/catalog-model'; +import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; +import { + Metric, + ThresholdConfig, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { GithubClient } from '../github/GithubClient'; +import { getRepositoryInformationFromEntity } from '../github/utils'; +import { PullRequestCommitStatus } from '../github/types'; + +const RATIO_THRESHOLDS: ThresholdConfig = { + rules: [ + { key: 'success', expression: '>=80' }, + { key: 'warning', expression: '50-79' }, + { key: 'error', expression: '<50' }, + ], +}; + +const METRIC_IDS = { + PASS_RATE_7D: 'github.ci_pass_rate_7d', + PASS_RATE_24H: 'github.ci_pass_rate_24h', +} as const; + +function computePassRate(statuses: PullRequestCommitStatus[]): number { + // Only consider PRs that have CI checks + const withCI = statuses.filter(s => s.firstPushLastCommitState !== null); + if (withCI.length === 0) { + return 100; + } + const passed = withCI.filter( + s => s.firstPushLastCommitState === 'SUCCESS', + ).length; + return Math.round((passed / withCI.length) * 1000) / 10; +} + +export class GithubCIPassRateProvider implements MetricProvider<'number'> { + private readonly githubClient: GithubClient; + + private constructor(config: Config) { + this.githubClient = new GithubClient(config); + } + + getProviderDatasourceId(): string { + return 'github'; + } + + getProviderId() { + return METRIC_IDS.PASS_RATE_7D; + } + + getMetricType(): 'number' { + return 'number'; + } + + getMetric(): Metric<'number'> { + return { + id: METRIC_IDS.PASS_RATE_7D, + title: 'GitHub CI pass rate (7d)', + description: + 'Percentage of PRs opened in the last 7 days where all CI statuses passed on the first push (percentage).', + type: this.getMetricType(), + history: true, + }; + } + + getMetricIds(): string[] { + return Object.values(METRIC_IDS); + } + + getMetrics(): Metric<'number'>[] { + return [ + this.getMetric(), + { + id: METRIC_IDS.PASS_RATE_24H, + title: 'GitHub CI pass rate (24h)', + description: + 'Percentage of PRs opened in the last 24 hours where all CI statuses passed on the first push (percentage).', + type: this.getMetricType(), + history: true, + }, + ]; + } + + getMetricThresholds(): ThresholdConfig { + return RATIO_THRESHOLDS; + } + + getCatalogFilter(): Record { + return { + 'metadata.annotations.github.com/project-slug': CATALOG_FILTER_EXISTS, + }; + } + + static fromConfig(config: Config): GithubCIPassRateProvider { + return new GithubCIPassRateProvider(config); + } + + async calculateMetric(entity: Entity): Promise { + const metrics = await this.calculateMetrics(entity); + return metrics.get(METRIC_IDS.PASS_RATE_7D) ?? 100; + } + + async calculateMetrics(entity: Entity): Promise> { + const repository = getRepositoryInformationFromEntity(entity); + const { target } = getEntitySourceLocation(entity); + + const since7d = new Date(); + since7d.setDate(since7d.getDate() - 7); + const sinceStr7d = since7d.toISOString().split('T')[0]; + + const statuses7d = + await this.githubClient.getPullRequestsWithCommitStatuses( + target, + repository, + sinceStr7d, + ); + + const cutoff24h = new Date(); + cutoff24h.setHours(cutoff24h.getHours() - 24); + const statuses24h = statuses7d.filter( + s => new Date(s.createdAt) >= cutoff24h, + ); + + const results = new Map(); + results.set(METRIC_IDS.PASS_RATE_7D, computePassRate(statuses7d)); + results.set(METRIC_IDS.PASS_RATE_24H, computePassRate(statuses24h)); + + return results; + } +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubClosedIssuesProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubClosedIssuesProvider.ts new file mode 100644 index 0000000000..4887113112 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubClosedIssuesProvider.ts @@ -0,0 +1,87 @@ +/* + * 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 { getEntitySourceLocation, 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 { GithubClient } from '../github/GithubClient'; +import { getRepositoryInformationFromEntity } from '../github/utils'; + +export class GithubClosedIssuesProvider implements MetricProvider<'number'> { + private readonly githubClient: GithubClient; + + private constructor(config: Config) { + this.githubClient = new GithubClient(config); + } + + getProviderDatasourceId(): string { + return 'github'; + } + + getProviderId() { + return 'github.closed_issues_7d'; + } + + getMetricType(): 'number' { + return 'number'; + } + + getMetric(): Metric<'number'> { + return { + id: this.getProviderId(), + title: 'GitHub closed issues (7d)', + description: + 'Number of issues closed in the last 7 days for a given GitHub repository.', + type: this.getMetricType(), + history: true, + }; + } + + getMetricThresholds(): ThresholdConfig { + return DEFAULT_NUMBER_THRESHOLDS; + } + + getCatalogFilter(): Record { + return { + 'metadata.annotations.github.com/project-slug': CATALOG_FILTER_EXISTS, + }; + } + + static fromConfig(config: Config): GithubClosedIssuesProvider { + return new GithubClosedIssuesProvider(config); + } + + async calculateMetric(entity: Entity): Promise { + const repository = getRepositoryInformationFromEntity(entity); + const { target } = getEntitySourceLocation(entity); + + const since = new Date(); + since.setDate(since.getDate() - 7); + const sinceStr = since.toISOString().split('T')[0]; + + return this.githubClient.getSearchCount( + target, + repository, + `is:issue is:closed closed:>${sinceStr}`, + ); + } +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubClosedPRsProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubClosedPRsProvider.ts new file mode 100644 index 0000000000..6d1e53c866 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubClosedPRsProvider.ts @@ -0,0 +1,87 @@ +/* + * 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 { getEntitySourceLocation, 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 { GithubClient } from '../github/GithubClient'; +import { getRepositoryInformationFromEntity } from '../github/utils'; + +export class GithubClosedPRsProvider implements MetricProvider<'number'> { + private readonly githubClient: GithubClient; + + private constructor(config: Config) { + this.githubClient = new GithubClient(config); + } + + getProviderDatasourceId(): string { + return 'github'; + } + + getProviderId() { + return 'github.closed_prs_7d'; + } + + getMetricType(): 'number' { + return 'number'; + } + + getMetric(): Metric<'number'> { + return { + id: this.getProviderId(), + title: 'GitHub closed PRs (7d)', + description: + 'Number of pull requests closed in the last 7 days for a given GitHub repository.', + type: this.getMetricType(), + history: true, + }; + } + + getMetricThresholds(): ThresholdConfig { + return DEFAULT_NUMBER_THRESHOLDS; + } + + getCatalogFilter(): Record { + return { + 'metadata.annotations.github.com/project-slug': CATALOG_FILTER_EXISTS, + }; + } + + static fromConfig(config: Config): GithubClosedPRsProvider { + return new GithubClosedPRsProvider(config); + } + + async calculateMetric(entity: Entity): Promise { + const repository = getRepositoryInformationFromEntity(entity); + const { target } = getEntitySourceLocation(entity); + + const since = new Date(); + since.setDate(since.getDate() - 7); + const sinceStr = since.toISOString().split('T')[0]; + + return this.githubClient.getSearchCount( + target, + repository, + `is:pr is:closed closed:>${sinceStr}`, + ); + } +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenIssuesProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenIssuesProvider.test.ts new file mode 100644 index 0000000000..2418331f6c --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenIssuesProvider.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 { ConfigReader } from '@backstage/config'; +import type { Entity } from '@backstage/catalog-model'; +import { GithubOpenIssuesProvider } from './GithubOpenIssuesProvider'; +import { GithubClient } from '../github/GithubClient'; +import { DEFAULT_NUMBER_THRESHOLDS } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +jest.mock('@backstage/catalog-model', () => ({ + ...jest.requireActual('@backstage/catalog-model'), + getEntitySourceLocation: jest.fn().mockReturnValue({ + type: 'url', + target: 'https://github.com/org/orgRepo/tree/main/', + }), +})); +jest.mock('../github/GithubClient'); + +describe('GithubOpenIssuesProvider', () => { + describe('fromConfig', () => { + it('should create provider with default thresholds', () => { + const provider = GithubOpenIssuesProvider.fromConfig( + new ConfigReader({}), + ); + + expect(provider.getMetricThresholds()).toEqual(DEFAULT_NUMBER_THRESHOLDS); + }); + }); + + describe('calculateMetric', () => { + let provider: GithubOpenIssuesProvider; + const mockedGithubClient = GithubClient as jest.MockedClass< + typeof GithubClient + >; + const mockedGithubClientInstance = { + getOpenIssuesCount: jest.fn(), + } as any; + mockedGithubClient.mockImplementation(() => mockedGithubClientInstance); + + beforeEach(() => { + jest.clearAllMocks(); + provider = GithubOpenIssuesProvider.fromConfig(new ConfigReader({})); + }); + + it('should calculate metric', async () => { + mockedGithubClientInstance.getOpenIssuesCount.mockResolvedValue(15); + const mockEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-component', + annotations: { + 'github.com/project-slug': 'org/orgRepo', + }, + }, + }; + + const result = await provider.calculateMetric(mockEntity); + + expect(result).toBe(15); + expect( + mockedGithubClientInstance.getOpenIssuesCount, + ).toHaveBeenCalledWith('https://github.com/org/orgRepo/tree/main/', { + owner: 'org', + repo: 'orgRepo', + }); + }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenIssuesProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenIssuesProvider.ts new file mode 100644 index 0000000000..ae75d9ef79 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenIssuesProvider.ts @@ -0,0 +1,79 @@ +/* + * 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 { getEntitySourceLocation, 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 { GithubClient } from '../github/GithubClient'; +import { getRepositoryInformationFromEntity } from '../github/utils'; + +export class GithubOpenIssuesProvider implements MetricProvider<'number'> { + private readonly githubClient: GithubClient; + + private constructor(config: Config) { + this.githubClient = new GithubClient(config); + } + + getProviderDatasourceId(): string { + return 'github'; + } + + getProviderId() { + return 'github.open_issues'; + } + + getMetricType(): 'number' { + return 'number'; + } + + getMetric(): Metric<'number'> { + return { + id: this.getProviderId(), + title: 'GitHub open issues', + description: + 'Current count of open issues (not PRs) for a given GitHub repository.', + type: this.getMetricType(), + history: true, + }; + } + + getMetricThresholds(): ThresholdConfig { + return DEFAULT_NUMBER_THRESHOLDS; + } + + getCatalogFilter(): Record { + return { + 'metadata.annotations.github.com/project-slug': CATALOG_FILTER_EXISTS, + }; + } + + static fromConfig(config: Config): GithubOpenIssuesProvider { + return new GithubOpenIssuesProvider(config); + } + + async calculateMetric(entity: Entity): Promise { + const repository = getRepositoryInformationFromEntity(entity); + const { target } = getEntitySourceLocation(entity); + + return this.githubClient.getOpenIssuesCount(target, repository); + } +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenedIssuesProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenedIssuesProvider.ts new file mode 100644 index 0000000000..73772e0eee --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenedIssuesProvider.ts @@ -0,0 +1,87 @@ +/* + * 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 { getEntitySourceLocation, 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 { GithubClient } from '../github/GithubClient'; +import { getRepositoryInformationFromEntity } from '../github/utils'; + +export class GithubOpenedIssuesProvider implements MetricProvider<'number'> { + private readonly githubClient: GithubClient; + + private constructor(config: Config) { + this.githubClient = new GithubClient(config); + } + + getProviderDatasourceId(): string { + return 'github'; + } + + getProviderId() { + return 'github.opened_issues_7d'; + } + + getMetricType(): 'number' { + return 'number'; + } + + getMetric(): Metric<'number'> { + return { + id: this.getProviderId(), + title: 'GitHub opened issues (7d)', + description: + 'Number of issues opened in the last 7 days for a given GitHub repository.', + type: this.getMetricType(), + history: true, + }; + } + + getMetricThresholds(): ThresholdConfig { + return DEFAULT_NUMBER_THRESHOLDS; + } + + getCatalogFilter(): Record { + return { + 'metadata.annotations.github.com/project-slug': CATALOG_FILTER_EXISTS, + }; + } + + static fromConfig(config: Config): GithubOpenedIssuesProvider { + return new GithubOpenedIssuesProvider(config); + } + + async calculateMetric(entity: Entity): Promise { + const repository = getRepositoryInformationFromEntity(entity); + const { target } = getEntitySourceLocation(entity); + + const since = new Date(); + since.setDate(since.getDate() - 7); + const sinceStr = since.toISOString().split('T')[0]; + + return this.githubClient.getSearchCount( + target, + repository, + `is:issue created:>${sinceStr}`, + ); + } +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenedPRsProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenedPRsProvider.ts new file mode 100644 index 0000000000..687b61b187 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenedPRsProvider.ts @@ -0,0 +1,87 @@ +/* + * 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 { getEntitySourceLocation, 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 { GithubClient } from '../github/GithubClient'; +import { getRepositoryInformationFromEntity } from '../github/utils'; + +export class GithubOpenedPRsProvider implements MetricProvider<'number'> { + private readonly githubClient: GithubClient; + + private constructor(config: Config) { + this.githubClient = new GithubClient(config); + } + + getProviderDatasourceId(): string { + return 'github'; + } + + getProviderId() { + return 'github.opened_prs_7d'; + } + + getMetricType(): 'number' { + return 'number'; + } + + getMetric(): Metric<'number'> { + return { + id: this.getProviderId(), + title: 'GitHub opened PRs (7d)', + description: + 'Number of pull requests opened in the last 7 days for a given GitHub repository.', + type: this.getMetricType(), + history: true, + }; + } + + getMetricThresholds(): ThresholdConfig { + return DEFAULT_NUMBER_THRESHOLDS; + } + + getCatalogFilter(): Record { + return { + 'metadata.annotations.github.com/project-slug': CATALOG_FILTER_EXISTS, + }; + } + + static fromConfig(config: Config): GithubOpenedPRsProvider { + return new GithubOpenedPRsProvider(config); + } + + async calculateMetric(entity: Entity): Promise { + const repository = getRepositoryInformationFromEntity(entity); + const { target } = getEntitySourceLocation(entity); + + const since = new Date(); + since.setDate(since.getDate() - 7); + const sinceStr = since.toISOString().split('T')[0]; + + return this.githubClient.getSearchCount( + target, + repository, + `is:pr created:>${sinceStr}`, + ); + } +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubPRLifecycleProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubPRLifecycleProvider.test.ts new file mode 100644 index 0000000000..70d3fbb1e8 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubPRLifecycleProvider.test.ts @@ -0,0 +1,159 @@ +/* + * 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 { GithubPRLifecycleProvider } from './GithubPRLifecycleProvider'; +import { GithubClient } from '../github/GithubClient'; + +jest.mock('@backstage/catalog-model', () => ({ + ...jest.requireActual('@backstage/catalog-model'), + getEntitySourceLocation: jest.fn().mockReturnValue({ + type: 'url', + target: 'https://github.com/org/orgRepo/tree/main/', + }), +})); +jest.mock('../github/GithubClient'); + +const mockEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-component', + annotations: { + 'github.com/project-slug': 'org/orgRepo', + }, + }, +}; + +describe('GithubPRLifecycleProvider', () => { + const mockedGithubClient = GithubClient as jest.MockedClass< + typeof GithubClient + >; + const mockedGithubClientInstance = { + getPullRequestsWithReviews: jest.fn(), + } as any; + mockedGithubClient.mockImplementation(() => mockedGithubClientInstance); + + let provider: GithubPRLifecycleProvider; + + beforeEach(() => { + jest.clearAllMocks(); + provider = GithubPRLifecycleProvider.fromConfig(new ConfigReader({})); + }); + + it('should return all metric IDs', () => { + expect(provider.getMetricIds()).toEqual([ + 'github.time_to_review', + 'github.time_to_approve', + 'github.time_to_merge', + ]); + }); + + it('should return all metrics', () => { + const metrics = provider.getMetrics!(); + expect(metrics).toHaveLength(3); + expect(metrics.map(m => m.id)).toEqual([ + 'github.time_to_review', + 'github.time_to_approve', + 'github.time_to_merge', + ]); + }); + + it('should calculate all lifecycle metrics', async () => { + const baseTime = new Date('2024-01-15T10:00:00Z').getTime(); + mockedGithubClientInstance.getPullRequestsWithReviews.mockResolvedValue([ + { + createdAt: new Date(baseTime).toISOString(), + mergedAt: new Date(baseTime + 48 * 60 * 60 * 1000).toISOString(), + reviews: { + nodes: [ + { + createdAt: new Date(baseTime + 12 * 60 * 60 * 1000).toISOString(), + state: 'COMMENTED', + }, + { + createdAt: new Date(baseTime + 24 * 60 * 60 * 1000).toISOString(), + state: 'APPROVED', + }, + ], + }, + }, + { + createdAt: new Date(baseTime).toISOString(), + mergedAt: new Date(baseTime + 72 * 60 * 60 * 1000).toISOString(), + reviews: { + nodes: [ + { + createdAt: new Date(baseTime + 24 * 60 * 60 * 1000).toISOString(), + state: 'APPROVED', + }, + ], + }, + }, + ]); + + const results = await provider.calculateMetrics!(mockEntity); + + // Time to review: avg of 12h and 24h = 18h + expect(results.get('github.time_to_review')).toBe(18); + // Time to approve: avg of 24h and 24h = 24h + expect(results.get('github.time_to_approve')).toBe(24); + // Time to merge: avg of 48h and 72h = 60h + expect(results.get('github.time_to_merge')).toBe(60); + }); + + it('should return 0 when no PRs have reviews', async () => { + mockedGithubClientInstance.getPullRequestsWithReviews.mockResolvedValue([ + { + createdAt: '2024-01-15T10:00:00Z', + mergedAt: null, + reviews: { nodes: [] }, + }, + ]); + + const results = await provider.calculateMetrics!(mockEntity); + + expect(results.get('github.time_to_review')).toBe(0); + expect(results.get('github.time_to_approve')).toBe(0); + }); + + it('should skip unmerged PRs for time to merge', async () => { + mockedGithubClientInstance.getPullRequestsWithReviews.mockResolvedValue([ + { + createdAt: '2024-01-15T10:00:00Z', + mergedAt: null, + reviews: { + nodes: [{ createdAt: '2024-01-15T22:00:00Z', state: 'APPROVED' }], + }, + }, + ]); + + const results = await provider.calculateMetrics!(mockEntity); + + expect(results.get('github.time_to_merge')).toBe(0); + }); + + it('should return 0 for empty results', async () => { + mockedGithubClientInstance.getPullRequestsWithReviews.mockResolvedValue([]); + + const results = await provider.calculateMetrics!(mockEntity); + + expect(results.get('github.time_to_review')).toBe(0); + expect(results.get('github.time_to_approve')).toBe(0); + expect(results.get('github.time_to_merge')).toBe(0); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubPRLifecycleProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubPRLifecycleProvider.ts new file mode 100644 index 0000000000..3afd38e35e --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubPRLifecycleProvider.ts @@ -0,0 +1,191 @@ +/* + * 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 { getEntitySourceLocation, type Entity } from '@backstage/catalog-model'; +import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; +import { + Metric, + ThresholdConfig, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { GithubClient } from '../github/GithubClient'; +import { getRepositoryInformationFromEntity } from '../github/utils'; +import { PullRequestWithReviews } from '../github/types'; + +const DURATION_THRESHOLDS: ThresholdConfig = { + rules: [ + { key: 'success', expression: '<24' }, + { key: 'warning', expression: '24-168' }, + { key: 'error', expression: '>168' }, + ], +}; + +const METRIC_IDS = { + TIME_TO_REVIEW: 'github.time_to_review', + TIME_TO_APPROVE: 'github.time_to_approve', + TIME_TO_MERGE: 'github.time_to_merge', +} as const; + +function computeAverageHours(durations: number[]): number { + if (durations.length === 0) { + return 0; + } + const totalMs = durations.reduce((sum, d) => sum + d, 0); + return Math.round((totalMs / durations.length / (1000 * 60 * 60)) * 10) / 10; +} + +function getTimeToFirstReview(prs: PullRequestWithReviews[]): number { + const durations: number[] = []; + for (const pr of prs) { + if (pr.reviews.nodes.length === 0) { + continue; + } + const prCreated = new Date(pr.createdAt).getTime(); + const firstReview = Math.min( + ...pr.reviews.nodes.map(r => new Date(r.createdAt).getTime()), + ); + durations.push(firstReview - prCreated); + } + return computeAverageHours(durations); +} + +function getTimeToFirstApproval(prs: PullRequestWithReviews[]): number { + const durations: number[] = []; + for (const pr of prs) { + const approvals = pr.reviews.nodes.filter(r => r.state === 'APPROVED'); + if (approvals.length === 0) { + continue; + } + const prCreated = new Date(pr.createdAt).getTime(); + const firstApproval = Math.min( + ...approvals.map(r => new Date(r.createdAt).getTime()), + ); + durations.push(firstApproval - prCreated); + } + return computeAverageHours(durations); +} + +function getTimeToMerge(prs: PullRequestWithReviews[]): number { + const durations: number[] = []; + for (const pr of prs) { + if (!pr.mergedAt) { + continue; + } + const prCreated = new Date(pr.createdAt).getTime(); + const merged = new Date(pr.mergedAt).getTime(); + durations.push(merged - prCreated); + } + return computeAverageHours(durations); +} + +export class GithubPRLifecycleProvider implements MetricProvider<'number'> { + private readonly githubClient: GithubClient; + + private constructor(config: Config) { + this.githubClient = new GithubClient(config); + } + + getProviderDatasourceId(): string { + return 'github'; + } + + getProviderId() { + return METRIC_IDS.TIME_TO_REVIEW; + } + + getMetricType(): 'number' { + return 'number'; + } + + getMetric(): Metric<'number'> { + return { + id: METRIC_IDS.TIME_TO_REVIEW, + title: 'GitHub time to review (7d)', + description: + 'Average hours from PR creation to first review for PRs updated in the last 7 days.', + type: this.getMetricType(), + history: true, + }; + } + + getMetricIds(): string[] { + return Object.values(METRIC_IDS); + } + + getMetrics(): Metric<'number'>[] { + return [ + this.getMetric(), + { + id: METRIC_IDS.TIME_TO_APPROVE, + title: 'GitHub time to approve (7d)', + description: + 'Average hours from PR creation to first approval for PRs updated in the last 7 days.', + type: this.getMetricType(), + history: true, + }, + { + id: METRIC_IDS.TIME_TO_MERGE, + title: 'GitHub time to merge (7d)', + description: + 'Average hours from PR creation to merge for merged PRs updated in the last 7 days.', + type: this.getMetricType(), + history: true, + }, + ]; + } + + getMetricThresholds(): ThresholdConfig { + return DURATION_THRESHOLDS; + } + + getCatalogFilter(): Record { + return { + 'metadata.annotations.github.com/project-slug': CATALOG_FILTER_EXISTS, + }; + } + + static fromConfig(config: Config): GithubPRLifecycleProvider { + return new GithubPRLifecycleProvider(config); + } + + async calculateMetric(entity: Entity): Promise { + const metrics = await this.calculateMetrics(entity); + return metrics.get(METRIC_IDS.TIME_TO_REVIEW) ?? 0; + } + + async calculateMetrics(entity: Entity): Promise> { + const repository = getRepositoryInformationFromEntity(entity); + const { target } = getEntitySourceLocation(entity); + + const since = new Date(); + since.setDate(since.getDate() - 7); + const sinceStr = since.toISOString().split('T')[0]; + + const prs = await this.githubClient.getPullRequestsWithReviews( + target, + repository, + sinceStr, + ); + + const results = new Map(); + results.set(METRIC_IDS.TIME_TO_REVIEW, getTimeToFirstReview(prs)); + results.set(METRIC_IDS.TIME_TO_APPROVE, getTimeToFirstApproval(prs)); + results.set(METRIC_IDS.TIME_TO_MERGE, getTimeToMerge(prs)); + + return results; + } +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubSearchCountProviders.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubSearchCountProviders.test.ts new file mode 100644 index 0000000000..9e8782a35c --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubSearchCountProviders.test.ts @@ -0,0 +1,151 @@ +/* + * 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 { GithubOpenedIssuesProvider } from './GithubOpenedIssuesProvider'; +import { GithubOpenedPRsProvider } from './GithubOpenedPRsProvider'; +import { GithubClosedIssuesProvider } from './GithubClosedIssuesProvider'; +import { GithubClosedPRsProvider } from './GithubClosedPRsProvider'; +import { GithubClient } from '../github/GithubClient'; + +jest.mock('@backstage/catalog-model', () => ({ + ...jest.requireActual('@backstage/catalog-model'), + getEntitySourceLocation: jest.fn().mockReturnValue({ + type: 'url', + target: 'https://github.com/org/orgRepo/tree/main/', + }), +})); +jest.mock('../github/GithubClient'); + +const mockEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-component', + annotations: { + 'github.com/project-slug': 'org/orgRepo', + }, + }, +}; + +describe('Search count providers', () => { + const mockedGithubClient = GithubClient as jest.MockedClass< + typeof GithubClient + >; + const mockedGithubClientInstance = { + getSearchCount: jest.fn(), + } as any; + mockedGithubClient.mockImplementation(() => mockedGithubClientInstance); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('GithubOpenedIssuesProvider', () => { + it('should return provider metadata', () => { + const provider = GithubOpenedIssuesProvider.fromConfig( + new ConfigReader({}), + ); + expect(provider.getProviderId()).toBe('github.opened_issues_7d'); + expect(provider.getProviderDatasourceId()).toBe('github'); + expect(provider.getMetricType()).toBe('number'); + }); + + it('should calculate metric using search count', async () => { + mockedGithubClientInstance.getSearchCount.mockResolvedValue(5); + const provider = GithubOpenedIssuesProvider.fromConfig( + new ConfigReader({}), + ); + + const result = await provider.calculateMetric(mockEntity); + + expect(result).toBe(5); + expect(mockedGithubClientInstance.getSearchCount).toHaveBeenCalledWith( + 'https://github.com/org/orgRepo/tree/main/', + { owner: 'org', repo: 'orgRepo' }, + expect.stringContaining('is:issue created:>'), + ); + }); + }); + + describe('GithubOpenedPRsProvider', () => { + it('should return provider metadata', () => { + const provider = GithubOpenedPRsProvider.fromConfig(new ConfigReader({})); + expect(provider.getProviderId()).toBe('github.opened_prs_7d'); + }); + + it('should calculate metric using search count', async () => { + mockedGithubClientInstance.getSearchCount.mockResolvedValue(10); + const provider = GithubOpenedPRsProvider.fromConfig(new ConfigReader({})); + + const result = await provider.calculateMetric(mockEntity); + + expect(result).toBe(10); + expect(mockedGithubClientInstance.getSearchCount).toHaveBeenCalledWith( + 'https://github.com/org/orgRepo/tree/main/', + { owner: 'org', repo: 'orgRepo' }, + expect.stringContaining('is:pr created:>'), + ); + }); + }); + + describe('GithubClosedIssuesProvider', () => { + it('should return provider metadata', () => { + const provider = GithubClosedIssuesProvider.fromConfig( + new ConfigReader({}), + ); + expect(provider.getProviderId()).toBe('github.closed_issues_7d'); + }); + + it('should calculate metric using search count', async () => { + mockedGithubClientInstance.getSearchCount.mockResolvedValue(3); + const provider = GithubClosedIssuesProvider.fromConfig( + new ConfigReader({}), + ); + + const result = await provider.calculateMetric(mockEntity); + + expect(result).toBe(3); + expect(mockedGithubClientInstance.getSearchCount).toHaveBeenCalledWith( + 'https://github.com/org/orgRepo/tree/main/', + { owner: 'org', repo: 'orgRepo' }, + expect.stringContaining('is:issue is:closed closed:>'), + ); + }); + }); + + describe('GithubClosedPRsProvider', () => { + it('should return provider metadata', () => { + const provider = GithubClosedPRsProvider.fromConfig(new ConfigReader({})); + expect(provider.getProviderId()).toBe('github.closed_prs_7d'); + }); + + it('should calculate metric using search count', async () => { + mockedGithubClientInstance.getSearchCount.mockResolvedValue(7); + const provider = GithubClosedPRsProvider.fromConfig(new ConfigReader({})); + + const result = await provider.calculateMetric(mockEntity); + + expect(result).toBe(7); + expect(mockedGithubClientInstance.getSearchCount).toHaveBeenCalledWith( + 'https://github.com/org/orgRepo/tree/main/', + { owner: 'org', repo: 'orgRepo' }, + expect.stringContaining('is:pr is:closed closed:>'), + ); + }); + }); +}); 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 267556de1c..a5015f7330 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/module.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/module.ts @@ -19,6 +19,14 @@ import { } from '@backstage/backend-plugin-api'; import { scorecardMetricsExtensionPoint } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; import { GithubOpenPRsProvider } from './metricProviders/GithubOpenPRsProvider'; +import { GithubOpenIssuesProvider } from './metricProviders/GithubOpenIssuesProvider'; +import { GithubOpenedIssuesProvider } from './metricProviders/GithubOpenedIssuesProvider'; +import { GithubOpenedPRsProvider } from './metricProviders/GithubOpenedPRsProvider'; +import { GithubClosedIssuesProvider } from './metricProviders/GithubClosedIssuesProvider'; +import { GithubClosedPRsProvider } from './metricProviders/GithubClosedPRsProvider'; +import { GithubPRLifecycleProvider } from './metricProviders/GithubPRLifecycleProvider'; +import { GithubActionsProvider } from './metricProviders/GithubActionsProvider'; +import { GithubCIPassRateProvider } from './metricProviders/GithubCIPassRateProvider'; export const scorecardModuleGithub = createBackendModule({ pluginId: 'scorecard', @@ -31,6 +39,18 @@ export const scorecardModuleGithub = createBackendModule({ }, async init({ config, metrics }) { metrics.addMetricProvider(GithubOpenPRsProvider.fromConfig(config)); + metrics.addMetricProvider(GithubOpenIssuesProvider.fromConfig(config)); + metrics.addMetricProvider( + GithubOpenedIssuesProvider.fromConfig(config), + ); + metrics.addMetricProvider(GithubOpenedPRsProvider.fromConfig(config)); + metrics.addMetricProvider( + GithubClosedIssuesProvider.fromConfig(config), + ); + metrics.addMetricProvider(GithubClosedPRsProvider.fromConfig(config)); + metrics.addMetricProvider(GithubPRLifecycleProvider.fromConfig(config)); + metrics.addMetricProvider(GithubActionsProvider.fromConfig(config)); + metrics.addMetricProvider(GithubCIPassRateProvider.fromConfig(config)); }, }); }, From 5f9d7aca994cbb66753451070702eaf85319137b Mon Sep 17 00:00:00 2001 From: fullsend-fix <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:06:18 +0000 Subject: [PATCH 2/2] fix: address review feedback on PR #3472 - Split GithubActionsProvider into GithubActionsCountProvider (with COUNT_THRESHOLDS) and GithubActionsRatioProvider (with RATIO_THRESHOLDS) so each provider gets the correct ThresholdConfig - Rename GithubCIPassRateProvider to GithubPRPassRateProvider with updated metric IDs (github.pr_ci_first_time_pass_rate_*) and titles mentioning FTPR (first time pass rate) - Add individual unit test files for GithubClosedIssuesProvider, GithubClosedPRsProvider, GithubOpenedIssuesProvider, and GithubOpenedPRsProvider - Apply encodeURIComponent to owner/repo in REST URL construction Addresses review feedback on #3472 --- .../src/github/GithubClient.ts | 6 +- .../GithubActionsCountProvider.test.ts | 155 ++++++++++++++++++ ...vider.ts => GithubActionsCountProvider.ts} | 58 +------ ....ts => GithubActionsRatioProvider.test.ts} | 52 +++--- .../GithubActionsRatioProvider.ts | 153 +++++++++++++++++ .../GithubClosedIssuesProvider.test.ts | 81 +++++++++ .../GithubClosedPRsProvider.test.ts | 79 +++++++++ .../GithubOpenedIssuesProvider.test.ts | 81 +++++++++ .../GithubOpenedPRsProvider.test.ts | 79 +++++++++ ...st.ts => GithubPRPassRateProvider.test.ts} | 26 +-- ...rovider.ts => GithubPRPassRateProvider.ts} | 18 +- .../src/module.ts | 14 +- 12 files changed, 688 insertions(+), 114 deletions(-) create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsCountProvider.test.ts rename workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/{GithubActionsProvider.ts => GithubActionsCountProvider.ts} (67%) rename workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/{GithubActionsProvider.test.ts => GithubActionsRatioProvider.test.ts} (74%) create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsRatioProvider.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubClosedIssuesProvider.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubClosedPRsProvider.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenedIssuesProvider.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenedPRsProvider.test.ts rename workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/{GithubCIPassRateProvider.test.ts => GithubPRPassRateProvider.test.ts} (82%) rename workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/{GithubCIPassRateProvider.ts => GithubPRPassRateProvider.ts} (83%) 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 ead479b2fb..3a25bf7e9b 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 @@ -218,7 +218,11 @@ export class GithubClient { let hasMore = true; while (hasMore) { - const restUrl = `${apiBaseUrl}/repos/${repository.owner}/${repository.repo}/actions/runs?created=>${since}&per_page=${perPage}&page=${page}`; + const restUrl = `${apiBaseUrl}/repos/${encodeURIComponent( + repository.owner, + )}/${encodeURIComponent( + repository.repo, + )}/actions/runs?created=>${since}&per_page=${perPage}&page=${page}`; const response = await fetch(restUrl, { headers }); if (!response.ok) { diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsCountProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsCountProvider.test.ts new file mode 100644 index 0000000000..2c68785050 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsCountProvider.test.ts @@ -0,0 +1,155 @@ +/* + * 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 { GithubActionsCountProvider } from './GithubActionsCountProvider'; +import { GithubClient } from '../github/GithubClient'; + +jest.mock('@backstage/catalog-model', () => ({ + ...jest.requireActual('@backstage/catalog-model'), + getEntitySourceLocation: jest.fn().mockReturnValue({ + type: 'url', + target: 'https://github.com/org/orgRepo/tree/main/', + }), +})); +jest.mock('../github/GithubClient'); + +const mockEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-component', + annotations: { + 'github.com/project-slug': 'org/orgRepo', + }, + }, +}; + +describe('GithubActionsCountProvider', () => { + const mockedGithubClient = GithubClient as jest.MockedClass< + typeof GithubClient + >; + const mockedGithubClientInstance = { + getWorkflowRuns: jest.fn(), + } as any; + mockedGithubClient.mockImplementation(() => mockedGithubClientInstance); + + let provider: GithubActionsCountProvider; + + beforeEach(() => { + jest.clearAllMocks(); + provider = GithubActionsCountProvider.fromConfig(new ConfigReader({})); + }); + + it('should return count metric IDs only', () => { + expect(provider.getMetricIds()).toEqual([ + 'github.actions_started_7d', + 'github.actions_successful_7d', + 'github.actions_failed_7d', + ]); + }); + + it('should return count metrics only', () => { + const metrics = provider.getMetrics!(); + expect(metrics).toHaveLength(3); + }); + + it('should use COUNT_THRESHOLDS', () => { + const thresholds = provider.getMetricThresholds(); + expect(thresholds.rules).toEqual([ + { key: 'success', expression: '<10' }, + { key: 'warning', expression: '10-50' }, + { key: 'error', expression: '>50' }, + ]); + }); + + it('should calculate count metrics', async () => { + const now = new Date(); + const hoursAgo = (h: number) => + new Date(now.getTime() - h * 60 * 60 * 1000).toISOString(); + + mockedGithubClientInstance.getWorkflowRuns.mockResolvedValue([ + { + status: 'completed', + conclusion: 'success', + created_at: hoursAgo(48), + }, + { + status: 'completed', + conclusion: 'success', + created_at: hoursAgo(72), + }, + { + status: 'completed', + conclusion: 'failure', + created_at: hoursAgo(96), + }, + { + status: 'in_progress', + conclusion: null, + created_at: hoursAgo(20), + }, + ]); + + const results = await provider.calculateMetrics!(mockEntity); + + expect(results.get('github.actions_started_7d')).toBe(4); + expect(results.get('github.actions_successful_7d')).toBe(2); + expect(results.get('github.actions_failed_7d')).toBe(1); + }); + + it('should handle empty workflow runs', async () => { + mockedGithubClientInstance.getWorkflowRuns.mockResolvedValue([]); + + const results = await provider.calculateMetrics!(mockEntity); + + expect(results.get('github.actions_started_7d')).toBe(0); + expect(results.get('github.actions_successful_7d')).toBe(0); + expect(results.get('github.actions_failed_7d')).toBe(0); + }); + + it('should exclude non-terminal runs from success/failure counts', async () => { + mockedGithubClientInstance.getWorkflowRuns.mockResolvedValue([ + { + status: 'queued', + conclusion: null, + created_at: new Date().toISOString(), + }, + { + status: 'in_progress', + conclusion: null, + created_at: new Date().toISOString(), + }, + { + status: 'completed', + conclusion: 'cancelled', + created_at: new Date().toISOString(), + }, + { + status: 'completed', + conclusion: 'success', + created_at: new Date().toISOString(), + }, + ]); + + const results = await provider.calculateMetrics!(mockEntity); + + expect(results.get('github.actions_started_7d')).toBe(4); + expect(results.get('github.actions_successful_7d')).toBe(1); + expect(results.get('github.actions_failed_7d')).toBe(0); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsCountProvider.ts similarity index 67% rename from workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsProvider.ts rename to workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsCountProvider.ts index 55d3e75f41..f2506f0f49 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsProvider.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsCountProvider.ts @@ -34,49 +34,19 @@ const COUNT_THRESHOLDS: ThresholdConfig = { ], }; -const RATIO_THRESHOLDS: ThresholdConfig = { - rules: [ - { key: 'success', expression: '>=80' }, - { key: 'warning', expression: '50-79' }, - { key: 'error', expression: '<50' }, - ], -}; - const METRIC_IDS = { STARTED: 'github.actions_started_7d', SUCCESSFUL: 'github.actions_successful_7d', FAILED: 'github.actions_failed_7d', - SUCCESS_RATIO_7D: 'github.actions_success_ratio_7d', - SUCCESS_RATIO_24H: 'github.actions_success_ratio_24h', } as const; -function filterRunsByWindow( - runs: WorkflowRun[], - hoursAgo: number, -): WorkflowRun[] { - const cutoff = new Date(); - cutoff.setHours(cutoff.getHours() - hoursAgo); - return runs.filter(r => new Date(r.created_at) >= cutoff); -} - function countByConclusion(runs: WorkflowRun[], conclusion: string): number { return runs.filter( r => r.status === 'completed' && r.conclusion === conclusion, ).length; } -function computeSuccessRatio(runs: WorkflowRun[]): number { - const completed = runs.filter(r => r.status === 'completed'); - const successful = completed.filter(r => r.conclusion === 'success'); - const failed = completed.filter(r => r.conclusion === 'failure'); - const total = successful.length + failed.length; - if (total === 0) { - return 100; - } - return Math.round((successful.length / total) * 1000) / 10; -} - -export class GithubActionsProvider implements MetricProvider<'number'> { +export class GithubActionsCountProvider implements MetricProvider<'number'> { private readonly githubClient: GithubClient; private constructor(config: Config) { @@ -129,22 +99,6 @@ export class GithubActionsProvider implements MetricProvider<'number'> { type: this.getMetricType(), history: true, }, - { - id: METRIC_IDS.SUCCESS_RATIO_7D, - title: 'GitHub Actions success ratio (7d)', - description: - 'Ratio of successful to successful+failed GitHub Actions workflow runs in the last 7 days (percentage).', - type: this.getMetricType(), - history: true, - }, - { - id: METRIC_IDS.SUCCESS_RATIO_24H, - title: 'GitHub Actions success ratio (24h)', - description: - 'Ratio of successful to successful+failed GitHub Actions workflow runs in the last 24 hours (percentage).', - type: this.getMetricType(), - history: true, - }, ]; } @@ -158,8 +112,8 @@ export class GithubActionsProvider implements MetricProvider<'number'> { }; } - static fromConfig(config: Config): GithubActionsProvider { - return new GithubActionsProvider(config); + static fromConfig(config: Config): GithubActionsCountProvider { + return new GithubActionsCountProvider(config); } async calculateMetric(entity: Entity): Promise { @@ -181,17 +135,13 @@ export class GithubActionsProvider implements MetricProvider<'number'> { sinceStr, ); - const runs24h = filterRunsByWindow(runs, 24); - const results = new Map(); results.set(METRIC_IDS.STARTED, runs.length); results.set(METRIC_IDS.SUCCESSFUL, countByConclusion(runs, 'success')); results.set(METRIC_IDS.FAILED, countByConclusion(runs, 'failure')); - results.set(METRIC_IDS.SUCCESS_RATIO_7D, computeSuccessRatio(runs)); - results.set(METRIC_IDS.SUCCESS_RATIO_24H, computeSuccessRatio(runs24h)); return results; } } -export { RATIO_THRESHOLDS, COUNT_THRESHOLDS }; +export { COUNT_THRESHOLDS }; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsRatioProvider.test.ts similarity index 74% rename from workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsProvider.test.ts rename to workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsRatioProvider.test.ts index bfe95ca09d..f8c43481ff 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsProvider.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsRatioProvider.test.ts @@ -16,7 +16,7 @@ import { ConfigReader } from '@backstage/config'; import type { Entity } from '@backstage/catalog-model'; -import { GithubActionsProvider } from './GithubActionsProvider'; +import { GithubActionsRatioProvider } from './GithubActionsRatioProvider'; import { GithubClient } from '../github/GithubClient'; jest.mock('@backstage/catalog-model', () => ({ @@ -39,7 +39,7 @@ const mockEntity: Entity = { }, }; -describe('GithubActionsProvider', () => { +describe('GithubActionsRatioProvider', () => { const mockedGithubClient = GithubClient as jest.MockedClass< typeof GithubClient >; @@ -48,35 +48,40 @@ describe('GithubActionsProvider', () => { } as any; mockedGithubClient.mockImplementation(() => mockedGithubClientInstance); - let provider: GithubActionsProvider; + let provider: GithubActionsRatioProvider; beforeEach(() => { jest.clearAllMocks(); - provider = GithubActionsProvider.fromConfig(new ConfigReader({})); + provider = GithubActionsRatioProvider.fromConfig(new ConfigReader({})); }); - it('should return all metric IDs', () => { + it('should return ratio metric IDs only', () => { expect(provider.getMetricIds()).toEqual([ - 'github.actions_started_7d', - 'github.actions_successful_7d', - 'github.actions_failed_7d', 'github.actions_success_ratio_7d', 'github.actions_success_ratio_24h', ]); }); - it('should return all metrics', () => { + it('should return ratio metrics only', () => { const metrics = provider.getMetrics!(); - expect(metrics).toHaveLength(5); + expect(metrics).toHaveLength(2); }); - it('should calculate all action metrics', async () => { + it('should use RATIO_THRESHOLDS', () => { + const thresholds = provider.getMetricThresholds(); + expect(thresholds.rules).toEqual([ + { key: 'success', expression: '>=80' }, + { key: 'warning', expression: '50-79' }, + { key: 'error', expression: '<50' }, + ]); + }); + + it('should calculate ratio metrics', async () => { const now = new Date(); const hoursAgo = (h: number) => new Date(now.getTime() - h * 60 * 60 * 1000).toISOString(); mockedGithubClientInstance.getWorkflowRuns.mockResolvedValue([ - // 7d window runs { status: 'completed', conclusion: 'success', @@ -97,7 +102,6 @@ describe('GithubActionsProvider', () => { conclusion: null, created_at: hoursAgo(20), }, - // 24h window runs { status: 'completed', conclusion: 'success', @@ -112,9 +116,6 @@ describe('GithubActionsProvider', () => { const results = await provider.calculateMetrics!(mockEntity); - expect(results.get('github.actions_started_7d')).toBe(6); - expect(results.get('github.actions_successful_7d')).toBe(3); - expect(results.get('github.actions_failed_7d')).toBe(2); // 7d ratio: 3 success / (3 success + 2 failure) = 60% expect(results.get('github.actions_success_ratio_7d')).toBe(60); // 24h ratio: 1 success / (1 success + 1 failure) = 50% @@ -133,6 +134,7 @@ describe('GithubActionsProvider', () => { const results = await provider.calculateMetrics!(mockEntity); expect(results.get('github.actions_success_ratio_7d')).toBe(100); + expect(results.get('github.actions_success_ratio_24h')).toBe(100); }); it('should handle empty workflow runs', async () => { @@ -140,25 +142,12 @@ describe('GithubActionsProvider', () => { const results = await provider.calculateMetrics!(mockEntity); - expect(results.get('github.actions_started_7d')).toBe(0); - expect(results.get('github.actions_successful_7d')).toBe(0); - expect(results.get('github.actions_failed_7d')).toBe(0); expect(results.get('github.actions_success_ratio_7d')).toBe(100); expect(results.get('github.actions_success_ratio_24h')).toBe(100); }); - it('should exclude non-terminal runs from counts', async () => { + it('should exclude cancelled runs from ratio calculation', async () => { mockedGithubClientInstance.getWorkflowRuns.mockResolvedValue([ - { - status: 'queued', - conclusion: null, - created_at: new Date().toISOString(), - }, - { - status: 'in_progress', - conclusion: null, - created_at: new Date().toISOString(), - }, { status: 'completed', conclusion: 'cancelled', @@ -173,9 +162,6 @@ describe('GithubActionsProvider', () => { const results = await provider.calculateMetrics!(mockEntity); - expect(results.get('github.actions_started_7d')).toBe(4); - expect(results.get('github.actions_successful_7d')).toBe(1); - expect(results.get('github.actions_failed_7d')).toBe(0); // Ratio: 1/(1+0) = 100% (cancelled excluded from ratio) expect(results.get('github.actions_success_ratio_7d')).toBe(100); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsRatioProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsRatioProvider.ts new file mode 100644 index 0000000000..d14c184f1f --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsRatioProvider.ts @@ -0,0 +1,153 @@ +/* + * 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 { getEntitySourceLocation, type Entity } from '@backstage/catalog-model'; +import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; +import { + Metric, + ThresholdConfig, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { GithubClient } from '../github/GithubClient'; +import { getRepositoryInformationFromEntity } from '../github/utils'; +import { WorkflowRun } from '../github/types'; + +const RATIO_THRESHOLDS: ThresholdConfig = { + rules: [ + { key: 'success', expression: '>=80' }, + { key: 'warning', expression: '50-79' }, + { key: 'error', expression: '<50' }, + ], +}; + +const METRIC_IDS = { + SUCCESS_RATIO_7D: 'github.actions_success_ratio_7d', + SUCCESS_RATIO_24H: 'github.actions_success_ratio_24h', +} as const; + +function filterRunsByWindow( + runs: WorkflowRun[], + hoursAgo: number, +): WorkflowRun[] { + const cutoff = new Date(); + cutoff.setHours(cutoff.getHours() - hoursAgo); + return runs.filter(r => new Date(r.created_at) >= cutoff); +} + +function computeSuccessRatio(runs: WorkflowRun[]): number { + const completed = runs.filter(r => r.status === 'completed'); + const successful = completed.filter(r => r.conclusion === 'success'); + const failed = completed.filter(r => r.conclusion === 'failure'); + const total = successful.length + failed.length; + if (total === 0) { + return 100; + } + return Math.round((successful.length / total) * 1000) / 10; +} + +export class GithubActionsRatioProvider implements MetricProvider<'number'> { + private readonly githubClient: GithubClient; + + private constructor(config: Config) { + this.githubClient = new GithubClient(config); + } + + getProviderDatasourceId(): string { + return 'github'; + } + + getProviderId() { + return METRIC_IDS.SUCCESS_RATIO_7D; + } + + getMetricType(): 'number' { + return 'number'; + } + + getMetric(): Metric<'number'> { + return { + id: METRIC_IDS.SUCCESS_RATIO_7D, + title: 'GitHub Actions success ratio (7d)', + description: + 'Ratio of successful to successful+failed GitHub Actions workflow runs in the last 7 days (percentage). Cancelled and skipped runs are excluded.', + type: this.getMetricType(), + history: true, + }; + } + + getMetricIds(): string[] { + return Object.values(METRIC_IDS); + } + + getMetrics(): Metric<'number'>[] { + return [ + this.getMetric(), + { + id: METRIC_IDS.SUCCESS_RATIO_24H, + title: 'GitHub Actions success ratio (24h)', + description: + 'Ratio of successful to successful+failed GitHub Actions workflow runs in the last 24 hours (percentage). Cancelled and skipped runs are excluded.', + type: this.getMetricType(), + history: true, + }, + ]; + } + + getMetricThresholds(): ThresholdConfig { + return RATIO_THRESHOLDS; + } + + getCatalogFilter(): Record { + return { + 'metadata.annotations.github.com/project-slug': CATALOG_FILTER_EXISTS, + }; + } + + static fromConfig(config: Config): GithubActionsRatioProvider { + return new GithubActionsRatioProvider(config); + } + + async calculateMetric(entity: Entity): Promise { + const metrics = await this.calculateMetrics(entity); + return metrics.get(METRIC_IDS.SUCCESS_RATIO_7D) ?? 100; + } + + async calculateMetrics(entity: Entity): Promise> { + const repository = getRepositoryInformationFromEntity(entity); + const { target } = getEntitySourceLocation(entity); + + const since = new Date(); + since.setDate(since.getDate() - 7); + const sinceStr = since.toISOString().split('T')[0]; + + const runs = await this.githubClient.getWorkflowRuns( + target, + repository, + sinceStr, + ); + + const runs24h = filterRunsByWindow(runs, 24); + + const results = new Map(); + results.set(METRIC_IDS.SUCCESS_RATIO_7D, computeSuccessRatio(runs)); + results.set(METRIC_IDS.SUCCESS_RATIO_24H, computeSuccessRatio(runs24h)); + + return results; + } +} + +export { RATIO_THRESHOLDS }; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubClosedIssuesProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubClosedIssuesProvider.test.ts new file mode 100644 index 0000000000..3c215a3758 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubClosedIssuesProvider.test.ts @@ -0,0 +1,81 @@ +/* + * 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 { GithubClosedIssuesProvider } from './GithubClosedIssuesProvider'; +import { GithubClient } from '../github/GithubClient'; +import { DEFAULT_NUMBER_THRESHOLDS } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +jest.mock('@backstage/catalog-model', () => ({ + ...jest.requireActual('@backstage/catalog-model'), + getEntitySourceLocation: jest.fn().mockReturnValue({ + type: 'url', + target: 'https://github.com/org/orgRepo/tree/main/', + }), +})); +jest.mock('../github/GithubClient'); + +describe('GithubClosedIssuesProvider', () => { + describe('fromConfig', () => { + it('should create provider with default thresholds', () => { + const provider = GithubClosedIssuesProvider.fromConfig( + new ConfigReader({}), + ); + + expect(provider.getMetricThresholds()).toEqual(DEFAULT_NUMBER_THRESHOLDS); + }); + }); + + describe('calculateMetric', () => { + let provider: GithubClosedIssuesProvider; + const mockedGithubClient = GithubClient as jest.MockedClass< + typeof GithubClient + >; + const mockedGithubClientInstance = { + getSearchCount: jest.fn(), + } as any; + mockedGithubClient.mockImplementation(() => mockedGithubClientInstance); + + beforeEach(() => { + jest.clearAllMocks(); + provider = GithubClosedIssuesProvider.fromConfig(new ConfigReader({})); + }); + + it('should calculate metric', async () => { + mockedGithubClientInstance.getSearchCount.mockResolvedValue(3); + const mockEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-component', + annotations: { + 'github.com/project-slug': 'org/orgRepo', + }, + }, + }; + + const result = await provider.calculateMetric(mockEntity); + + expect(result).toBe(3); + expect(mockedGithubClientInstance.getSearchCount).toHaveBeenCalledWith( + 'https://github.com/org/orgRepo/tree/main/', + { owner: 'org', repo: 'orgRepo' }, + expect.stringContaining('is:issue is:closed closed:>'), + ); + }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubClosedPRsProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubClosedPRsProvider.test.ts new file mode 100644 index 0000000000..4554b4233f --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubClosedPRsProvider.test.ts @@ -0,0 +1,79 @@ +/* + * 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 { GithubClosedPRsProvider } from './GithubClosedPRsProvider'; +import { GithubClient } from '../github/GithubClient'; +import { DEFAULT_NUMBER_THRESHOLDS } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +jest.mock('@backstage/catalog-model', () => ({ + ...jest.requireActual('@backstage/catalog-model'), + getEntitySourceLocation: jest.fn().mockReturnValue({ + type: 'url', + target: 'https://github.com/org/orgRepo/tree/main/', + }), +})); +jest.mock('../github/GithubClient'); + +describe('GithubClosedPRsProvider', () => { + describe('fromConfig', () => { + it('should create provider with default thresholds', () => { + const provider = GithubClosedPRsProvider.fromConfig(new ConfigReader({})); + + expect(provider.getMetricThresholds()).toEqual(DEFAULT_NUMBER_THRESHOLDS); + }); + }); + + describe('calculateMetric', () => { + let provider: GithubClosedPRsProvider; + const mockedGithubClient = GithubClient as jest.MockedClass< + typeof GithubClient + >; + const mockedGithubClientInstance = { + getSearchCount: jest.fn(), + } as any; + mockedGithubClient.mockImplementation(() => mockedGithubClientInstance); + + beforeEach(() => { + jest.clearAllMocks(); + provider = GithubClosedPRsProvider.fromConfig(new ConfigReader({})); + }); + + it('should calculate metric', async () => { + mockedGithubClientInstance.getSearchCount.mockResolvedValue(7); + const mockEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-component', + annotations: { + 'github.com/project-slug': 'org/orgRepo', + }, + }, + }; + + const result = await provider.calculateMetric(mockEntity); + + expect(result).toBe(7); + expect(mockedGithubClientInstance.getSearchCount).toHaveBeenCalledWith( + 'https://github.com/org/orgRepo/tree/main/', + { owner: 'org', repo: 'orgRepo' }, + expect.stringContaining('is:pr is:closed closed:>'), + ); + }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenedIssuesProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenedIssuesProvider.test.ts new file mode 100644 index 0000000000..58abbe22ae --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenedIssuesProvider.test.ts @@ -0,0 +1,81 @@ +/* + * 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 { GithubOpenedIssuesProvider } from './GithubOpenedIssuesProvider'; +import { GithubClient } from '../github/GithubClient'; +import { DEFAULT_NUMBER_THRESHOLDS } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +jest.mock('@backstage/catalog-model', () => ({ + ...jest.requireActual('@backstage/catalog-model'), + getEntitySourceLocation: jest.fn().mockReturnValue({ + type: 'url', + target: 'https://github.com/org/orgRepo/tree/main/', + }), +})); +jest.mock('../github/GithubClient'); + +describe('GithubOpenedIssuesProvider', () => { + describe('fromConfig', () => { + it('should create provider with default thresholds', () => { + const provider = GithubOpenedIssuesProvider.fromConfig( + new ConfigReader({}), + ); + + expect(provider.getMetricThresholds()).toEqual(DEFAULT_NUMBER_THRESHOLDS); + }); + }); + + describe('calculateMetric', () => { + let provider: GithubOpenedIssuesProvider; + const mockedGithubClient = GithubClient as jest.MockedClass< + typeof GithubClient + >; + const mockedGithubClientInstance = { + getSearchCount: jest.fn(), + } as any; + mockedGithubClient.mockImplementation(() => mockedGithubClientInstance); + + beforeEach(() => { + jest.clearAllMocks(); + provider = GithubOpenedIssuesProvider.fromConfig(new ConfigReader({})); + }); + + it('should calculate metric', async () => { + mockedGithubClientInstance.getSearchCount.mockResolvedValue(5); + const mockEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-component', + annotations: { + 'github.com/project-slug': 'org/orgRepo', + }, + }, + }; + + const result = await provider.calculateMetric(mockEntity); + + expect(result).toBe(5); + expect(mockedGithubClientInstance.getSearchCount).toHaveBeenCalledWith( + 'https://github.com/org/orgRepo/tree/main/', + { owner: 'org', repo: 'orgRepo' }, + expect.stringContaining('is:issue created:>'), + ); + }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenedPRsProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenedPRsProvider.test.ts new file mode 100644 index 0000000000..56d0415355 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubOpenedPRsProvider.test.ts @@ -0,0 +1,79 @@ +/* + * 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 { GithubOpenedPRsProvider } from './GithubOpenedPRsProvider'; +import { GithubClient } from '../github/GithubClient'; +import { DEFAULT_NUMBER_THRESHOLDS } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +jest.mock('@backstage/catalog-model', () => ({ + ...jest.requireActual('@backstage/catalog-model'), + getEntitySourceLocation: jest.fn().mockReturnValue({ + type: 'url', + target: 'https://github.com/org/orgRepo/tree/main/', + }), +})); +jest.mock('../github/GithubClient'); + +describe('GithubOpenedPRsProvider', () => { + describe('fromConfig', () => { + it('should create provider with default thresholds', () => { + const provider = GithubOpenedPRsProvider.fromConfig(new ConfigReader({})); + + expect(provider.getMetricThresholds()).toEqual(DEFAULT_NUMBER_THRESHOLDS); + }); + }); + + describe('calculateMetric', () => { + let provider: GithubOpenedPRsProvider; + const mockedGithubClient = GithubClient as jest.MockedClass< + typeof GithubClient + >; + const mockedGithubClientInstance = { + getSearchCount: jest.fn(), + } as any; + mockedGithubClient.mockImplementation(() => mockedGithubClientInstance); + + beforeEach(() => { + jest.clearAllMocks(); + provider = GithubOpenedPRsProvider.fromConfig(new ConfigReader({})); + }); + + it('should calculate metric', async () => { + mockedGithubClientInstance.getSearchCount.mockResolvedValue(10); + const mockEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-component', + annotations: { + 'github.com/project-slug': 'org/orgRepo', + }, + }, + }; + + const result = await provider.calculateMetric(mockEntity); + + expect(result).toBe(10); + expect(mockedGithubClientInstance.getSearchCount).toHaveBeenCalledWith( + 'https://github.com/org/orgRepo/tree/main/', + { owner: 'org', repo: 'orgRepo' }, + expect.stringContaining('is:pr created:>'), + ); + }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubCIPassRateProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubPRPassRateProvider.test.ts similarity index 82% rename from workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubCIPassRateProvider.test.ts rename to workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubPRPassRateProvider.test.ts index 3d9ee46e79..66a86bce2e 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubCIPassRateProvider.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubPRPassRateProvider.test.ts @@ -16,7 +16,7 @@ import { ConfigReader } from '@backstage/config'; import type { Entity } from '@backstage/catalog-model'; -import { GithubCIPassRateProvider } from './GithubCIPassRateProvider'; +import { GithubPRPassRateProvider } from './GithubPRPassRateProvider'; import { GithubClient } from '../github/GithubClient'; jest.mock('@backstage/catalog-model', () => ({ @@ -39,7 +39,7 @@ const mockEntity: Entity = { }, }; -describe('GithubCIPassRateProvider', () => { +describe('GithubPRPassRateProvider', () => { const mockedGithubClient = GithubClient as jest.MockedClass< typeof GithubClient >; @@ -48,21 +48,21 @@ describe('GithubCIPassRateProvider', () => { } as any; mockedGithubClient.mockImplementation(() => mockedGithubClientInstance); - let provider: GithubCIPassRateProvider; + let provider: GithubPRPassRateProvider; beforeEach(() => { jest.clearAllMocks(); - provider = GithubCIPassRateProvider.fromConfig(new ConfigReader({})); + provider = GithubPRPassRateProvider.fromConfig(new ConfigReader({})); }); it('should return all metric IDs', () => { expect(provider.getMetricIds()).toEqual([ - 'github.ci_pass_rate_7d', - 'github.ci_pass_rate_24h', + 'github.pr_ci_first_time_pass_rate_7d', + 'github.pr_ci_first_time_pass_rate_24h', ]); }); - it('should calculate CI pass rates', async () => { + it('should calculate PR CI first time pass rates', async () => { const now = new Date(); const hoursAgo = (h: number) => new Date(now.getTime() - h * 60 * 60 * 1000).toISOString(); @@ -95,9 +95,9 @@ describe('GithubCIPassRateProvider', () => { const results = await provider.calculateMetrics!(mockEntity); // 7d: 3 success out of 4 with CI = 75% - expect(results.get('github.ci_pass_rate_7d')).toBe(75); + expect(results.get('github.pr_ci_first_time_pass_rate_7d')).toBe(75); // 24h: 2 success out of 2 with CI = 100% - expect(results.get('github.ci_pass_rate_24h')).toBe(100); + expect(results.get('github.pr_ci_first_time_pass_rate_24h')).toBe(100); }); it('should skip PRs without CI checks', async () => { @@ -112,7 +112,7 @@ describe('GithubCIPassRateProvider', () => { const results = await provider.calculateMetrics!(mockEntity); - expect(results.get('github.ci_pass_rate_7d')).toBe(100); + expect(results.get('github.pr_ci_first_time_pass_rate_7d')).toBe(100); }); it('should return 100% when no PRs exist', async () => { @@ -122,8 +122,8 @@ describe('GithubCIPassRateProvider', () => { const results = await provider.calculateMetrics!(mockEntity); - expect(results.get('github.ci_pass_rate_7d')).toBe(100); - expect(results.get('github.ci_pass_rate_24h')).toBe(100); + expect(results.get('github.pr_ci_first_time_pass_rate_7d')).toBe(100); + expect(results.get('github.pr_ci_first_time_pass_rate_24h')).toBe(100); }); it('should handle mixed CI states', async () => { @@ -154,6 +154,6 @@ describe('GithubCIPassRateProvider', () => { const results = await provider.calculateMetrics!(mockEntity); // 1 success out of 3 with CI (null excluded) = 33.3% - expect(results.get('github.ci_pass_rate_7d')).toBe(33.3); + expect(results.get('github.pr_ci_first_time_pass_rate_7d')).toBe(33.3); }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubCIPassRateProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubPRPassRateProvider.ts similarity index 83% rename from workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubCIPassRateProvider.ts rename to workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubPRPassRateProvider.ts index 90f96ee4ea..9a211396c4 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubCIPassRateProvider.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubPRPassRateProvider.ts @@ -35,8 +35,8 @@ const RATIO_THRESHOLDS: ThresholdConfig = { }; const METRIC_IDS = { - PASS_RATE_7D: 'github.ci_pass_rate_7d', - PASS_RATE_24H: 'github.ci_pass_rate_24h', + PASS_RATE_7D: 'github.pr_ci_first_time_pass_rate_7d', + PASS_RATE_24H: 'github.pr_ci_first_time_pass_rate_24h', } as const; function computePassRate(statuses: PullRequestCommitStatus[]): number { @@ -51,7 +51,7 @@ function computePassRate(statuses: PullRequestCommitStatus[]): number { return Math.round((passed / withCI.length) * 1000) / 10; } -export class GithubCIPassRateProvider implements MetricProvider<'number'> { +export class GithubPRPassRateProvider implements MetricProvider<'number'> { private readonly githubClient: GithubClient; private constructor(config: Config) { @@ -73,9 +73,9 @@ export class GithubCIPassRateProvider implements MetricProvider<'number'> { getMetric(): Metric<'number'> { return { id: METRIC_IDS.PASS_RATE_7D, - title: 'GitHub CI pass rate (7d)', + title: 'GitHub PR CI first time pass rate (7d)', description: - 'Percentage of PRs opened in the last 7 days where all CI statuses passed on the first push (percentage).', + 'First time pass rate (FTPR): percentage of PRs opened in the last 7 days where all CI statuses passed on the first push (percentage). PRs without CI checks are excluded.', type: this.getMetricType(), history: true, }; @@ -90,9 +90,9 @@ export class GithubCIPassRateProvider implements MetricProvider<'number'> { this.getMetric(), { id: METRIC_IDS.PASS_RATE_24H, - title: 'GitHub CI pass rate (24h)', + title: 'GitHub PR CI first time pass rate (24h)', description: - 'Percentage of PRs opened in the last 24 hours where all CI statuses passed on the first push (percentage).', + 'First time pass rate (FTPR): percentage of PRs opened in the last 24 hours where all CI statuses passed on the first push (percentage). PRs without CI checks are excluded.', type: this.getMetricType(), history: true, }, @@ -109,8 +109,8 @@ export class GithubCIPassRateProvider implements MetricProvider<'number'> { }; } - static fromConfig(config: Config): GithubCIPassRateProvider { - return new GithubCIPassRateProvider(config); + static fromConfig(config: Config): GithubPRPassRateProvider { + return new GithubPRPassRateProvider(config); } async calculateMetric(entity: Entity): Promise { 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 a5015f7330..17412d2774 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/module.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/module.ts @@ -25,8 +25,9 @@ import { GithubOpenedPRsProvider } from './metricProviders/GithubOpenedPRsProvid import { GithubClosedIssuesProvider } from './metricProviders/GithubClosedIssuesProvider'; import { GithubClosedPRsProvider } from './metricProviders/GithubClosedPRsProvider'; import { GithubPRLifecycleProvider } from './metricProviders/GithubPRLifecycleProvider'; -import { GithubActionsProvider } from './metricProviders/GithubActionsProvider'; -import { GithubCIPassRateProvider } from './metricProviders/GithubCIPassRateProvider'; +import { GithubActionsCountProvider } from './metricProviders/GithubActionsCountProvider'; +import { GithubActionsRatioProvider } from './metricProviders/GithubActionsRatioProvider'; +import { GithubPRPassRateProvider } from './metricProviders/GithubPRPassRateProvider'; export const scorecardModuleGithub = createBackendModule({ pluginId: 'scorecard', @@ -49,8 +50,13 @@ export const scorecardModuleGithub = createBackendModule({ ); metrics.addMetricProvider(GithubClosedPRsProvider.fromConfig(config)); metrics.addMetricProvider(GithubPRLifecycleProvider.fromConfig(config)); - metrics.addMetricProvider(GithubActionsProvider.fromConfig(config)); - metrics.addMetricProvider(GithubCIPassRateProvider.fromConfig(config)); + metrics.addMetricProvider( + GithubActionsCountProvider.fromConfig(config), + ); + metrics.addMetricProvider( + GithubActionsRatioProvider.fromConfig(config), + ); + metrics.addMetricProvider(GithubPRPassRateProvider.fromConfig(config)); }, }); },