Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-github': minor
---

Add AI adoption rate metric provider to the GitHub scorecard module.
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,15 @@ This metric counts all pull requests that are currently in an "open" state for t
- **Datasource**: `github`
- **Unit**: open pull requests (count)

### GitHub AI adoption rate (`github.aiAdoptionRate[7d]`, `[30d]`, `[90d]`)

These metrics calculate the ratio of AI-assisted commits on the repository's default branch over 7-day, 30-day, and 90-day time windows. A commit is considered AI-assisted when it contains a `Co-authored-by` or `Assisted-by` trailer referencing a known AI tool (Claude, Copilot, Cursor, Codeium, Cody, Tabnine, Gemini, Amazon Q, Windsurf, Devin, Aider). Merge commits are excluded from the calculation.

- **Metric IDs**: `github.aiAdoptionRate[7d]`, `github.aiAdoptionRate[30d]`, `github.aiAdoptionRate[90d]`
- **Metric Provider ID**: `github.aiAdoption`
- **Type**: Number (ratio from 0 to 1)
- **Datasource**: `github`

## Collectors

This module registers collectors to collect data from GitHub to be used by composite metric providers:
Expand Down Expand Up @@ -156,6 +165,24 @@ scorecard:
expression: '>50'
```

Default thresholds for `github.aiAdoption`:

```yaml
# app-config.yaml
scorecard:
metricProviders:
github:
aiAdoption:
thresholds:
rules:
- key: success
expression: '>=0.2'
- key: warning
expression: '>=0.1'
- key: error
expression: '>=0'
```

See [threshold configuration](../scorecard-backend/docs/thresholds.md) for custom thresholds configuration.

## Configuration
Expand All @@ -178,4 +205,20 @@ scorecard:
seconds: 5
```

The `aiAdoption` provider also supports an independent schedule:

```yaml
scorecard:
metricProviders:
github:
aiAdoption:
schedule:
frequency:
cron: '0 6 * * *'
timeout:
minutes: 5
initialDelay:
seconds: 5
```

The schedule configuration follows Backstage's `SchedulerServiceTaskScheduleDefinitionConfig` [schema](https://github.com/backstage/backstage/blob/master/packages/backend-plugin-api/src/services/definitions/SchedulerService.ts#L157). See [Metric Collection Scheduling](../scorecard-backend/docs/providers.md#metric-collection-scheduling) for custom schedule configuration.
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ export interface Config {
/** How github.openPRs metric values are categorized */
thresholds?: ThresholdConfig;
};
aiAdoption?: {
/** How often github.aiAdoption metrics will be calculated */
schedule?: SchedulerServiceTaskScheduleDefinitionConfig;
/** How github.aiAdoption metric values are categorized */
thresholds?: ThresholdConfig;
};
};
};
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,254 @@ describe('GithubClient', () => {
});
});

describe('getCommitHistory', () => {
it('should return commits from the default branch', async () => {
const url = `https://github.com/owner/repo`;
const since = new Date('2026-05-01T00:00:00.000Z');
mockedGraphqlClient.mockResolvedValue({
repository: {
defaultBranchRef: {
target: {
history: {
nodes: [
{
message: 'feat: add feature',
committedDate: '2026-05-15T10:00:00.000Z',
},
{
message: 'fix: bug fix',
committedDate: '2026-05-10T10:00:00.000Z',
},
],
pageInfo: {
hasNextPage: false,
endCursor: null,
},
totalCount: 2,
},
},
},
},
});

const commits = await githubClient.getCommitHistory(
url,
repository,
since,
);

expect(commits).toEqual([
{
message: 'feat: add feature',
committedDate: '2026-05-15T10:00:00.000Z',
},
{
message: 'fix: bug fix',
committedDate: '2026-05-10T10:00:00.000Z',
},
]);
expect(mockedGraphqlClient).toHaveBeenCalledTimes(1);
expect(mockedGraphqlClient).toHaveBeenCalledWith(
expect.stringContaining('query getCommitHistory'),
expect.objectContaining({
owner: repository.owner,
repo: repository.repo,
since: since.toISOString(),
after: null,
}),
);
expect(getCredentialsSpy).toHaveBeenCalledWith({ url });
});

it('should paginate across multiple pages', async () => {
const url = `https://github.com/owner/repo`;
const since = new Date('2026-05-01T00:00:00.000Z');
mockedGraphqlClient
.mockResolvedValueOnce({
repository: {
defaultBranchRef: {
target: {
history: {
nodes: [
{
message: 'feat: page one',
committedDate: '2026-05-20T10:00:00.000Z',
},
],
pageInfo: {
hasNextPage: true,
endCursor: 'cursor-1',
},
totalCount: 2,
},
},
},
},
})
.mockResolvedValueOnce({
repository: {
defaultBranchRef: {
target: {
history: {
nodes: [
{
message: 'fix: page two',
committedDate: '2026-05-10T10:00:00.000Z',
},
],
pageInfo: {
hasNextPage: false,
endCursor: null,
},
totalCount: 2,
},
},
},
},
});

const commits = await githubClient.getCommitHistory(
url,
repository,
since,
);

expect(commits).toHaveLength(2);
expect(commits[0].message).toBe('feat: page one');
expect(commits[1].message).toBe('fix: page two');
expect(mockedGraphqlClient).toHaveBeenCalledTimes(2);
expect(mockedGraphqlClient).toHaveBeenNthCalledWith(
2,
expect.stringContaining('query getCommitHistory'),
expect.objectContaining({ after: 'cursor-1' }),
);
});

it('should stop paging once fetchItemsLimit is reached', async () => {
const url = `https://github.com/owner/repo`;
const since = new Date('2026-05-01T00:00:00.000Z');
mockedGraphqlClient.mockResolvedValueOnce({
repository: {
defaultBranchRef: {
target: {
history: {
nodes: [
{
message: 'feat: one',
committedDate: '2026-05-20T10:00:00.000Z',
},
{
message: 'feat: two',
committedDate: '2026-05-15T10:00:00.000Z',
},
{
message: 'feat: three',
committedDate: '2026-05-10T10:00:00.000Z',
},
],
pageInfo: {
hasNextPage: true,
endCursor: 'cursor-1',
},
totalCount: 5,
},
},
},
},
});

const commits = await githubClient.getCommitHistory(
url,
repository,
since,
{ fetchItemsLimit: 2 },
);

expect(commits).toHaveLength(2);
expect(commits[0].message).toBe('feat: one');
expect(commits[1].message).toBe('feat: two');
expect(mockedGraphqlClient).toHaveBeenCalledTimes(1);
expect(mockedLogger.warn).toHaveBeenCalledWith(
'Reached fetchItemsLimit of 2 for commit history in owner/repo; stopping fetch',
);
});

it('should throw when repository is not found or inaccessible', async () => {
const url = `https://github.com/owner/repo`;
mockedGraphqlClient.mockResolvedValue({ repository: null });

await expect(
githubClient.getCommitHistory(
url,
repository,
new Date('2026-05-01T00:00:00.000Z'),
),
).rejects.toThrow(
`GitHub repository '${repository.owner}/${repository.repo}' was not found or is inaccessible`,
);
});

it('should skip null nodes in commit history', async () => {
const url = `https://github.com/owner/repo`;
const since = new Date('2026-05-01T00:00:00.000Z');
mockedGraphqlClient.mockResolvedValue({
repository: {
defaultBranchRef: {
target: {
history: {
nodes: [
{
message: 'feat: valid commit',
committedDate: '2026-05-15T10:00:00.000Z',
},
null,
{
message: 'fix: another valid',
committedDate: '2026-05-10T10:00:00.000Z',
},
],
pageInfo: {
hasNextPage: false,
endCursor: null,
},
totalCount: 3,
},
},
},
},
});

const commits = await githubClient.getCommitHistory(
url,
repository,
since,
);

expect(commits).toHaveLength(2);
});

it('should warn and return empty when defaultBranchRef is null', async () => {
const url = `https://github.com/owner/repo`;
const since = new Date('2026-05-01T00:00:00.000Z');
mockedGraphqlClient.mockResolvedValue({
repository: {
defaultBranchRef: null,
},
});

const commits = await githubClient.getCommitHistory(
url,
repository,
since,
);

expect(commits).toEqual([]);
expect(mockedLogger.warn).toHaveBeenCalledWith(
'No default branch found for owner/repo; returning empty commit history',
);
});
});

describe('getWorkflowRuns', () => {
it('should return workflow runs filtered by workflow name and date window in ascending order', async () => {
const url = `https://github.com/owner/repo`;
Expand Down
Loading
Loading