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..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 @@ -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,214 @@ 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/${encodeURIComponent( + repository.owner, + )}/${encodeURIComponent( + 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/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/GithubActionsCountProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsCountProvider.ts new file mode 100644 index 0000000000..f2506f0f49 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsCountProvider.ts @@ -0,0 +1,147 @@ +/* + * 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 METRIC_IDS = { + STARTED: 'github.actions_started_7d', + SUCCESSFUL: 'github.actions_successful_7d', + FAILED: 'github.actions_failed_7d', +} as const; + +function countByConclusion(runs: WorkflowRun[], conclusion: string): number { + return runs.filter( + r => r.status === 'completed' && r.conclusion === conclusion, + ).length; +} + +export class GithubActionsCountProvider 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, + }, + ]; + } + + getMetricThresholds(): ThresholdConfig { + return COUNT_THRESHOLDS; + } + + getCatalogFilter(): Record { + return { + 'metadata.annotations.github.com/project-slug': CATALOG_FILTER_EXISTS, + }; + } + + static fromConfig(config: Config): GithubActionsCountProvider { + return new GithubActionsCountProvider(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 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')); + + return results; + } +} + +export { COUNT_THRESHOLDS }; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsRatioProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsRatioProvider.test.ts new file mode 100644 index 0000000000..f8c43481ff --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubActionsRatioProvider.test.ts @@ -0,0 +1,168 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import type { Entity } from '@backstage/catalog-model'; +import { GithubActionsRatioProvider } from './GithubActionsRatioProvider'; +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('GithubActionsRatioProvider', () => { + const mockedGithubClient = GithubClient as jest.MockedClass< + typeof GithubClient + >; + const mockedGithubClientInstance = { + getWorkflowRuns: jest.fn(), + } as any; + mockedGithubClient.mockImplementation(() => mockedGithubClientInstance); + + let provider: GithubActionsRatioProvider; + + beforeEach(() => { + jest.clearAllMocks(); + provider = GithubActionsRatioProvider.fromConfig(new ConfigReader({})); + }); + + it('should return ratio metric IDs only', () => { + expect(provider.getMetricIds()).toEqual([ + 'github.actions_success_ratio_7d', + 'github.actions_success_ratio_24h', + ]); + }); + + it('should return ratio metrics only', () => { + const metrics = provider.getMetrics!(); + expect(metrics).toHaveLength(2); + }); + + 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([ + { + 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), + }, + { + status: 'completed', + conclusion: 'success', + created_at: hoursAgo(2), + }, + { + status: 'completed', + conclusion: 'failure', + created_at: hoursAgo(5), + }, + ]); + + const results = await provider.calculateMetrics!(mockEntity); + + // 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); + expect(results.get('github.actions_success_ratio_24h')).toBe(100); + }); + + it('should handle empty workflow runs', async () => { + mockedGithubClientInstance.getWorkflowRuns.mockResolvedValue([]); + + 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 exclude cancelled runs from ratio calculation', async () => { + mockedGithubClientInstance.getWorkflowRuns.mockResolvedValue([ + { + status: 'completed', + conclusion: 'cancelled', + created_at: new Date().toISOString(), + }, + { + status: 'completed', + conclusion: 'success', + created_at: new Date().toISOString(), + }, + ]); + + const results = await provider.calculateMetrics!(mockEntity); + + // 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/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.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/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.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/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.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/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/GithubPRPassRateProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubPRPassRateProvider.test.ts new file mode 100644 index 0000000000..66a86bce2e --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubPRPassRateProvider.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 { GithubPRPassRateProvider } from './GithubPRPassRateProvider'; +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('GithubPRPassRateProvider', () => { + const mockedGithubClient = GithubClient as jest.MockedClass< + typeof GithubClient + >; + const mockedGithubClientInstance = { + getPullRequestsWithCommitStatuses: jest.fn(), + } as any; + mockedGithubClient.mockImplementation(() => mockedGithubClientInstance); + + let provider: GithubPRPassRateProvider; + + beforeEach(() => { + jest.clearAllMocks(); + provider = GithubPRPassRateProvider.fromConfig(new ConfigReader({})); + }); + + it('should return all metric IDs', () => { + expect(provider.getMetricIds()).toEqual([ + 'github.pr_ci_first_time_pass_rate_7d', + 'github.pr_ci_first_time_pass_rate_24h', + ]); + }); + + 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(); + + 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.pr_ci_first_time_pass_rate_7d')).toBe(75); + // 24h: 2 success out of 2 with CI = 100% + expect(results.get('github.pr_ci_first_time_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.pr_ci_first_time_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.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 () => { + 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.pr_ci_first_time_pass_rate_7d')).toBe(33.3); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubPRPassRateProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubPRPassRateProvider.ts new file mode 100644 index 0000000000..9a211396c4 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubPRPassRateProvider.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.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 { + // 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 GithubPRPassRateProvider 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 PR CI first time pass rate (7d)', + description: + '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, + }; + } + + getMetricIds(): string[] { + return Object.values(METRIC_IDS); + } + + getMetrics(): Metric<'number'>[] { + return [ + this.getMetric(), + { + id: METRIC_IDS.PASS_RATE_24H, + title: 'GitHub PR CI first time pass rate (24h)', + description: + '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, + }, + ]; + } + + getMetricThresholds(): ThresholdConfig { + return RATIO_THRESHOLDS; + } + + getCatalogFilter(): Record { + return { + 'metadata.annotations.github.com/project-slug': CATALOG_FILTER_EXISTS, + }; + } + + static fromConfig(config: Config): GithubPRPassRateProvider { + return new GithubPRPassRateProvider(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/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..17412d2774 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,15 @@ 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 { GithubActionsCountProvider } from './metricProviders/GithubActionsCountProvider'; +import { GithubActionsRatioProvider } from './metricProviders/GithubActionsRatioProvider'; +import { GithubPRPassRateProvider } from './metricProviders/GithubPRPassRateProvider'; export const scorecardModuleGithub = createBackendModule({ pluginId: 'scorecard', @@ -31,6 +40,23 @@ 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( + GithubActionsCountProvider.fromConfig(config), + ); + metrics.addMetricProvider( + GithubActionsRatioProvider.fromConfig(config), + ); + metrics.addMetricProvider(GithubPRPassRateProvider.fromConfig(config)); }, }); },