Skip to content

Commit 07c5289

Browse files
committed
feat: implement batch provider for file checks
Signed-off-by: Diana Janickova <djanicko@redhat.com> test: implement and update unit tests Assisted-By: Cursor Signed-off-by: Diana Janickova <djanicko@redhat.com> chore: generate api reports Signed-off-by: Diana Janickova <djanicko@redhat.com> ref: use metricId instead of providerId Signed-off-by: Diana Janickova <djanicko@redhat.com> ref: Apply suggestions from code review Co-authored-by: Ihor Mykhno <imykhno@redhat.com> fix: run prettier Signed-off-by: Diana Janickova <djanicko@redhat.com> fix: update expected string in test Signed-off-by: Diana Janickova <djanicko@redhat.com>
1 parent 3a17273 commit 07c5289

File tree

21 files changed

+1427
-83
lines changed

21 files changed

+1427
-83
lines changed

workspaces/scorecard/plugins/scorecard-backend-module-github/README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,51 @@ This metric counts all pull requests that are currently in an "open" state for t
8585
expression: '<10'
8686
```
8787

88+
### GitHub File Checks (`github.files_check.*`)
89+
90+
This metric provider checks whether specific files exist in a repository. It's useful for enforcing best practices like having a `README.md`, `LICENSE`, `CODEOWNERS`, or other required files.
91+
92+
- **Metric ID**: `github.files_check.<file_id>` (e.g., `github.files_check.readme`)
93+
- **Type**: Boolean
94+
- **Datasource**: `github`
95+
- **Default thresholds**:
96+
- `success`: File exists (`==true`)
97+
- `error`: File is missing (`==false`)
98+
99+
#### Configuration
100+
101+
To enable file checks, add a `files_check` configuration in your `app-config.yaml`:
102+
103+
```yaml
104+
# app-config.yaml
105+
scorecard:
106+
plugins:
107+
github:
108+
files_check:
109+
files:
110+
- readme: 'README.md'
111+
- license: 'LICENSE'
112+
- codeowners: 'CODEOWNERS'
113+
- dockerfile: 'Dockerfile'
114+
```
115+
116+
Each entry in the `files` array creates a separate metric:
117+
118+
- The **key** (e.g., `readme`) becomes the metric identifier suffix (`github.files_check.readme`)
119+
- The **value** (e.g., `README.md`) is the file path to check in the repository
120+
121+
#### File Path Format
122+
123+
File paths must be relative to the repository root:
124+
125+
| Format | Example | Valid |
126+
| ---------------- | ---------------------- | ----- |
127+
| Root file | `README.md` | ✅ |
128+
| Subdirectory | `docs/CONTRIBUTING.md` | ✅ |
129+
| Hidden file | `.gitignore` | ✅ |
130+
| With `./` prefix | `./README.md` | ❌ |
131+
| Absolute path | `/home/file.txt` | ❌ |
132+
88133
## Configuration
89134

90135
### Threshold Configuration
@@ -107,6 +152,16 @@ scorecard:
107152
minutes: 5
108153
initialDelay:
109154
seconds: 5
155+
files_check:
156+
files:
157+
- readme: 'README.md'
158+
schedule:
159+
frequency:
160+
cron: '0 6 * * *'
161+
timeout:
162+
minutes: 5
163+
initialDelay:
164+
seconds: 5
110165
```
111166

112167
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).

workspaces/scorecard/plugins/scorecard-backend-module-github/config.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ export interface Config {
3232
};
3333
schedule?: SchedulerServiceTaskScheduleDefinitionConfig;
3434
};
35+
files_check?: {
36+
/** File existence checks configuration */
37+
files?: Array<{
38+
/** Key is the metric identifier, value is the file path */
39+
[metricId: string]: string;
40+
}>;
41+
schedule?: SchedulerServiceTaskScheduleDefinitionConfig;
42+
};
3543
};
3644
};
3745
};

workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GitHubClient.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,4 +93,83 @@ describe('GithubClient', () => {
9393
).rejects.toThrow(`Missing GitHub integration for '${unknownUrl}'`);
9494
});
9595
});
96+
97+
describe('checkFilesExist', () => {
98+
it('should return true for files that exist and false for those that do not', async () => {
99+
const url = 'https://github.com/owner/repo';
100+
const files = new Map<string, string>([
101+
['github.files_check.readme', 'README.md'],
102+
['github.files_check.license', 'LICENSE'],
103+
['github.files_check.codeowners', 'CODEOWNERS'],
104+
]);
105+
106+
const response = {
107+
repository: {
108+
github_files_check_readme: { id: 'abc123' },
109+
github_files_check_license: null,
110+
github_files_check_codeowners: { id: 'def456' },
111+
},
112+
};
113+
mockedGraphqlClient.mockResolvedValue(response);
114+
115+
const result = await githubClient.checkFilesExist(url, repository, files);
116+
117+
expect(result.get('github.files_check.readme')).toBe(true);
118+
expect(result.get('github.files_check.license')).toBe(false);
119+
expect(result.get('github.files_check.codeowners')).toBe(true);
120+
expect(mockedGraphqlClient).toHaveBeenCalledTimes(1);
121+
expect(mockedGraphqlClient).toHaveBeenCalledWith(
122+
expect.stringContaining('query checkFilesExist'),
123+
{ owner: 'owner', repo: 'repo' },
124+
);
125+
expect(getCredentialsSpy).toHaveBeenCalledWith({ url });
126+
});
127+
128+
it('should sanitize metric IDs with special characters to valid GraphQL aliases', async () => {
129+
const url = 'https://github.com/owner/repo';
130+
const files = new Map<string, string>([
131+
['github.files_check.my-file', 'my-file.txt'],
132+
]);
133+
134+
const response = {
135+
repository: {
136+
github_files_check_my_file: { id: 'xyz789' },
137+
},
138+
};
139+
mockedGraphqlClient.mockResolvedValue(response);
140+
141+
const result = await githubClient.checkFilesExist(url, repository, files);
142+
143+
expect(result.get('github.files_check.my-file')).toBe(true);
144+
expect(mockedGraphqlClient).toHaveBeenCalledWith(
145+
expect.stringContaining('github_files_check_my_file'),
146+
expect.any(Object),
147+
);
148+
});
149+
150+
it('should return an empty map when no files are provided', async () => {
151+
const url = 'https://github.com/owner/repo';
152+
const files = new Map<string, string>();
153+
154+
const response = {
155+
repository: {},
156+
};
157+
mockedGraphqlClient.mockResolvedValue(response);
158+
159+
const result = await githubClient.checkFilesExist(url, repository, files);
160+
161+
expect(result.size).toBe(0);
162+
});
163+
164+
it('should throw error when GitHub integration for URL is missing', async () => {
165+
const unknownUrl = 'https://unknown-host/owner/repo';
166+
const files = new Map<string, string>([
167+
['github.files_check.readme', 'README.md'],
168+
]);
169+
170+
await expect(
171+
githubClient.checkFilesExist(unknownUrl, repository, files),
172+
).rejects.toThrow(`Missing GitHub integration for '${unknownUrl}'`);
173+
});
174+
});
96175
});

workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GithubClient.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,56 @@ export class GithubClient {
7777

7878
return response.repository.pullRequests.totalCount;
7979
}
80+
81+
/**
82+
* Sanitize a string to be a valid GraphQL alias.
83+
* eg. "github.files_check.readme-correct" -> "github_files_check_readme_correct"
84+
*/
85+
private sanitizeGraphQLAlias(alias: string): string {
86+
return alias.replace(/[^_0-9A-Za-z]/g, '_');
87+
}
88+
89+
async checkFilesExist(
90+
url: string,
91+
repository: GithubRepository,
92+
files: Map<string, string>,
93+
): Promise<Map<string, boolean>> {
94+
const octokit = await this.getOctokitClient(url);
95+
96+
const aliasToMetricId = new Map<string, string>();
97+
const fileChecksParts: string[] = [];
98+
99+
for (const [metricId, path] of files) {
100+
const sanitizedAlias = this.sanitizeGraphQLAlias(metricId);
101+
102+
aliasToMetricId.set(sanitizedAlias, metricId);
103+
fileChecksParts.push(
104+
`${sanitizedAlias}: object(expression: "HEAD:${path}") { id }`,
105+
);
106+
}
107+
108+
const fileChecks = fileChecksParts.join('\n');
109+
110+
const query = `
111+
query checkFilesExist($owner: String!, $repo: String!) {
112+
repository(owner: $owner, name: $repo) {
113+
${fileChecks}
114+
}
115+
}
116+
`;
117+
118+
const response = await octokit<{
119+
repository: Record<string, { id: string } | null>;
120+
}>(query, {
121+
owner: repository.owner,
122+
repo: repository.repo,
123+
});
124+
125+
// Map results back to original metric IDs
126+
const results = new Map<string, boolean>();
127+
for (const [sanitizedAlias, metricId] of aliasToMetricId) {
128+
results.set(metricId, response.repository[sanitizedAlias] !== null);
129+
}
130+
return results;
131+
}
80132
}

workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,18 @@ export type GithubRepository = {
1717
owner: string;
1818
repo: string;
1919
};
20+
21+
/**
22+
* Single file to check
23+
*/
24+
export type GithubFile = {
25+
id: string;
26+
path: string;
27+
};
28+
29+
/**
30+
* Configuration for a file existence check
31+
*/
32+
export type GithubFilesConfig = {
33+
files: GithubFile[];
34+
};

0 commit comments

Comments
 (0)