Skip to content

Commit 7583066

Browse files
authored
feat(scorecard): fetch github open PRs (#1372)
* Introduce fetching github PRs Signed-off-by: Dominika Zemanovicova <dzemanov@redhat.com> Assisted-by: Cursor Desktop * Use directly octokit graphql Signed-off-by: Dominika Zemanovicova <dzemanov@redhat.com> * Introduce tests Signed-off-by: Dominika Zemanovicova <dzemanov@redhat.com> Assisted-by: Cursor Desktop * Add docs Signed-off-by: Dominika Zemanovicova <dzemanov@redhat.com> Assisted-by: Cursor Desktop * Update auth to octokit/graphql Signed-off-by: Dominika Zemanovicova <dzemanov@redhat.com> * Introduce github constants Signed-off-by: Dominika Zemanovicova <dzemanov@redhat.com> * Support octokit esm Signed-off-by: Dominika Zemanovicova <dzemanov@redhat.com> --------- Signed-off-by: Dominika Zemanovicova <dzemanov@redhat.com>
1 parent 98633c8 commit 7583066

14 files changed

Lines changed: 722 additions & 13 deletions

File tree

workspaces/scorecard/app-config.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ catalog:
9494
rules:
9595
- allow: [User, Group]
9696

97+
- type: url
98+
target: https://github.com/redhat-developer/rhdh/blob/main/catalog-entities/components/showcase.yaml
99+
97100
## Uncomment these lines to add more example data
98101
# - type: url
99102
# target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/all.yaml

workspaces/scorecard/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717
"build-image": "yarn workspace backend build-image",
1818
"build:knip-reports": "backstage-repo-tools knip-reports",
1919
"clean": "backstage-cli repo clean",
20-
"test": "backstage-cli repo test",
21-
"test:all": "backstage-cli repo test --coverage",
20+
"test": "NODE_OPTIONS='--experimental-vm-modules' backstage-cli repo test",
21+
"test:all": "NODE_OPTIONS='--experimental-vm-modules' backstage-cli repo test --coverage",
2222
"test:e2e": "playwright test",
2323
"fix": "backstage-cli repo fix",
2424
"lint": "backstage-cli repo lint --since origin/main",
Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,92 @@
1-
# @@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-github
1+
# Scorecard Backend Module for GitHub
22

3-
The github backend module for the scorecard plugin.
3+
This is an extension module to the `backstage-plugin-scorecard-backend` plugin. It provides GitHub-specific metrics for software components registered in the Backstage catalog.
44

5-
_This plugin was created through the Backstage CLI_
5+
## Prerequisites
6+
7+
Before installing this module, ensure that the Scorecard backend plugin is integrated into your Backstage instance. Follow the [Scorecard backend plugin README](../scorecard-backend/README.md) for setup instructions.
8+
9+
This module also requires a GitHub integration to be configured in your `app-config.yaml`. It uses Backstage's standard GitHub integration configuration, you can check the [docs](https://backstage.io/docs/integrations/github/locations/#configuration) to see all options.
10+
11+
## Installation
12+
13+
To install this backend module:
14+
15+
```bash
16+
# From your root directory
17+
yarn workspace backend add @red-hat-developer-hub/backstage-plugin-scorecard-backend-module-github
18+
```
19+
20+
```ts
21+
// packages/backend/src/index.ts
22+
import { createBackend } from '@backstage/backend-defaults';
23+
24+
const backend = createBackend();
25+
26+
// Scorecard backend plugin
27+
backend.add(
28+
import('@red-hat-developer-hub/backstage-plugin-scorecard-backend'),
29+
);
30+
31+
// Install the GitHub module
32+
/* highlight-add-next-line */
33+
backend.add(
34+
import(
35+
'@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-github'
36+
),
37+
);
38+
39+
backend.start();
40+
```
41+
42+
### Entity Annotations
43+
44+
For the GitHub metrics to work, your catalog entities must have the required GitHub annotations:
45+
46+
```yaml
47+
# catalog-info.yaml
48+
apiVersion: backstage.io/v1alpha1
49+
kind: Component
50+
metadata:
51+
name: my-service
52+
annotations:
53+
# Required: GitHub project slug in format "owner/repository"
54+
github.com/project-slug: myorg/my-service
55+
spec:
56+
type: service
57+
lifecycle: production
58+
owner: team-a
59+
```
60+
61+
## Available Metrics
62+
63+
### GitHub Open Pull Requests (`github.open-prs`)
64+
65+
This metric counts all pull requests that are currently in an "open" state for the repository specified in the entity's `github.com/project-slug` annotation.
66+
67+
- **Metric ID**: `github.open-prs`
68+
- **Type**: Number
69+
- **Datasource**: `github`
70+
- **Default thresholds**:
71+
72+
```yaml
73+
# app-config.yaml
74+
scorecard:
75+
plugins:
76+
github:
77+
open_prs:
78+
thresholds:
79+
rules:
80+
- key: error
81+
expression: '>50'
82+
- key: warning
83+
expression: '10-50'
84+
- key: success
85+
expression: '<10'
86+
```
87+
88+
## Configuration
89+
90+
### Threshold Configuration
91+
92+
Thresholds define conditions that determine which category a metric value belongs to ( `error`, `warning`, or `success`). You can configure custom thresholds for the GitHub metrics. Check out detailed explanation of [threshold configuration](../scorecard-backend/docs/thresholds.md).

workspaces/scorecard/plugins/scorecard-backend-module-github/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,21 @@
3333
"prepack": "backstage-cli package prepack",
3434
"postpack": "backstage-cli package postpack",
3535
"start": "backstage-cli package start",
36-
"test": "backstage-cli package test",
36+
"test": "NODE_OPTIONS='--experimental-vm-modules' backstage-cli package test",
3737
"tsc": "tsc",
3838
"prettier:check": "prettier --ignore-unknown --check .",
3939
"prettier:fix": "prettier --ignore-unknown --write ."
4040
},
4141
"dependencies": {
4242
"@backstage/backend-plugin-api": "^1.4.1",
43+
"@backstage/catalog-model": "^1.7.5",
44+
"@backstage/integration": "^1.16.0",
45+
"@octokit/graphql": "^9.0.1",
4346
"@red-hat-developer-hub/backstage-plugin-scorecard-common": "workspace:^",
4447
"@red-hat-developer-hub/backstage-plugin-scorecard-node": "workspace:^"
4548
},
4649
"devDependencies": {
4750
"@backstage/backend-test-utils": "^1.7.0",
48-
"@backstage/catalog-model": "^1.7.5",
4951
"@backstage/cli": "^0.33.1",
5052
"@backstage/config": "^1.3.3"
5153
},
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { ConfigReader } from '@backstage/config';
18+
import { DefaultGithubCredentialsProvider } from '@backstage/integration';
19+
import { GithubClient } from './GithubClient';
20+
import { GithubRepository } from './types';
21+
import { DEFAULT_GITHUB_HOSTNAME } from './constants';
22+
23+
describe('GithubClient', () => {
24+
let githubClient: GithubClient;
25+
const mockedGraphqlClient = jest.fn();
26+
const repository: GithubRepository = {
27+
owner: 'owner',
28+
repo: 'repo',
29+
};
30+
31+
jest
32+
.spyOn(DefaultGithubCredentialsProvider.prototype, 'getCredentials')
33+
.mockResolvedValue({
34+
type: 'token',
35+
headers: { Authorization: 'Bearer dummy-token' },
36+
token: 'dummy-token',
37+
});
38+
39+
beforeEach(() => {
40+
jest.clearAllMocks();
41+
42+
// @ts-ignore
43+
jest.unstable_mockModule('@octokit/graphql', async () => ({
44+
graphql: {
45+
defaults: () => mockedGraphqlClient,
46+
},
47+
}));
48+
49+
const mockConfig = new ConfigReader({
50+
integrations: {
51+
github: [
52+
{
53+
host: DEFAULT_GITHUB_HOSTNAME,
54+
token: 'dummy-token',
55+
},
56+
],
57+
},
58+
});
59+
githubClient = new GithubClient(mockConfig);
60+
});
61+
62+
describe('getOpenPullRequestsCount', () => {
63+
it('should return the count of open pull requests', async () => {
64+
const response = {
65+
repository: {
66+
pullRequests: {
67+
totalCount: 42,
68+
},
69+
},
70+
};
71+
mockedGraphqlClient.mockResolvedValue(response);
72+
73+
const result = await githubClient.getOpenPullRequestsCount(
74+
repository,
75+
DEFAULT_GITHUB_HOSTNAME,
76+
);
77+
78+
expect(result).toBe(42);
79+
expect(mockedGraphqlClient).toHaveBeenCalledTimes(1);
80+
expect(mockedGraphqlClient).toHaveBeenCalledWith(
81+
expect.stringContaining('query getOpenPRsCount'),
82+
repository,
83+
);
84+
});
85+
86+
it('should throw error when GitHub integration for hostname is missing', async () => {
87+
await expect(
88+
githubClient.getOpenPullRequestsCount(repository, 'unknown-host'),
89+
).rejects.toThrow("Missing GitHub integration for 'unknown-host'");
90+
});
91+
});
92+
});
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import type { Config } from '@backstage/config';
18+
import {
19+
DefaultGithubCredentialsProvider,
20+
ScmIntegrations,
21+
} from '@backstage/integration';
22+
import { GithubRepository } from './types';
23+
import { DEFAULT_GITHUB_HOSTNAME } from './constants';
24+
25+
export class GithubClient {
26+
private readonly integrations: ScmIntegrations;
27+
28+
constructor(config: Config) {
29+
this.integrations = ScmIntegrations.fromConfig(config);
30+
}
31+
32+
private async getOctokitClient(
33+
hostname: string = DEFAULT_GITHUB_HOSTNAME,
34+
): Promise<typeof graphql> {
35+
const githubIntegration = this.integrations.github.byHost(hostname);
36+
if (!githubIntegration) {
37+
throw new Error(`Missing GitHub integration for '${hostname}'`);
38+
}
39+
40+
const credentialsProvider =
41+
DefaultGithubCredentialsProvider.fromIntegrations(this.integrations);
42+
43+
const { headers } = await credentialsProvider.getCredentials({
44+
url: `https://${hostname}`,
45+
});
46+
47+
const { graphql } = await import('@octokit/graphql');
48+
return graphql.defaults({
49+
headers,
50+
baseUrl: githubIntegration.config.apiBaseUrl,
51+
});
52+
}
53+
54+
async getOpenPullRequestsCount(
55+
repository: GithubRepository,
56+
hostname: string,
57+
): Promise<number> {
58+
const octokit = await this.getOctokitClient(hostname);
59+
60+
const query = `
61+
query getOpenPRsCount($owner: String!, $repo: String!) {
62+
repository(owner: $owner, name: $repo) {
63+
pullRequests(states: OPEN) {
64+
totalCount
65+
}
66+
}
67+
}
68+
`;
69+
70+
const response = await octokit<{
71+
repository: {
72+
pullRequests: {
73+
totalCount: number;
74+
};
75+
};
76+
}>(query, {
77+
owner: repository.owner,
78+
repo: repository.repo,
79+
});
80+
81+
return response.repository.pullRequests.totalCount;
82+
}
83+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
export const DEFAULT_GITHUB_HOSTNAME = 'github.com';
18+
export const GITHUB_PROJECT_ANNOTATION = 'github.com/project-slug';
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
export type GithubRepository = {
17+
owner: string;
18+
repo: string;
19+
};

0 commit comments

Comments
 (0)