Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
Expand Up @@ -85,6 +85,51 @@ This metric counts all pull requests that are currently in an "open" state for t
expression: '<10'
```

### GitHub File Checks (`github.files_check.*`)

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.

- **Metric ID**: `github.files_check.<file_id>` (e.g., `github.files_check.readme`)
- **Type**: Boolean
- **Datasource**: `github`
- **Default thresholds**:
- `success`: File exists (`==true`)
- `error`: File is missing (`==false`)

#### Configuration

To enable file checks, add a `files_check` configuration in your `app-config.yaml`:

```yaml
# app-config.yaml
scorecard:
plugins:
github:
files_check:
files:
- readme: 'README.md'
- license: 'LICENSE'
- codeowners: 'CODEOWNERS'
- dockerfile: 'Dockerfile'
```

Each entry in the `files` array creates a separate metric:

- The **key** (e.g., `readme`) becomes the metric identifier suffix (`github.files_check.readme`)
- The **value** (e.g., `README.md`) is the file path to check in the repository

#### File Path Format

File paths must be relative to the repository root:

| Format | Example | Valid |
| ---------------- | ---------------------- | ----- |
| Root file | `README.md` | ✅ |
| Subdirectory | `docs/CONTRIBUTING.md` | ✅ |
| Hidden file | `.gitignore` | ✅ |
| With `./` prefix | `./README.md` | ❌ |
| Absolute path | `/home/file.txt` | ❌ |

## Configuration

### Threshold Configuration
Expand All @@ -107,6 +152,16 @@ scorecard:
minutes: 5
initialDelay:
seconds: 5
files_check:
files:
- readme: 'README.md'
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).
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ export interface Config {
};
schedule?: SchedulerServiceTaskScheduleDefinitionConfig;
};
files_check?: {
/** File existence checks configuration */
files?: Array<{
/** Key is the metric identifier, value is the file path */
[metricId: string]: string;
}>;
schedule?: SchedulerServiceTaskScheduleDefinitionConfig;
};
};
};
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,83 @@ describe('GithubClient', () => {
).rejects.toThrow(`Missing GitHub integration for '${unknownUrl}'`);
});
});

describe('checkFilesExist', () => {
it('should return true for files that exist and false for those that do not', async () => {
const url = 'https://github.com/owner/repo';
const files = new Map<string, string>([
['github.files_check.readme', 'README.md'],
['github.files_check.license', 'LICENSE'],
['github.files_check.codeowners', 'CODEOWNERS'],
]);

const response = {
repository: {
github_files_check_readme: { id: 'abc123' },
github_files_check_license: null,
github_files_check_codeowners: { id: 'def456' },
},
};
mockedGraphqlClient.mockResolvedValue(response);

const result = await githubClient.checkFilesExist(url, repository, files);

expect(result.get('github.files_check.readme')).toBe(true);
expect(result.get('github.files_check.license')).toBe(false);
expect(result.get('github.files_check.codeowners')).toBe(true);
expect(mockedGraphqlClient).toHaveBeenCalledTimes(1);
expect(mockedGraphqlClient).toHaveBeenCalledWith(
expect.stringContaining('query checkFilesExist'),
{ owner: 'owner', repo: 'repo' },
);
expect(getCredentialsSpy).toHaveBeenCalledWith({ url });
});

it('should sanitize metric IDs with special characters to valid GraphQL aliases', async () => {
const url = 'https://github.com/owner/repo';
const files = new Map<string, string>([
['github.files_check.my-file', 'my-file.txt'],
]);

const response = {
repository: {
github_files_check_my_file: { id: 'xyz789' },
},
};
mockedGraphqlClient.mockResolvedValue(response);

const result = await githubClient.checkFilesExist(url, repository, files);

expect(result.get('github.files_check.my-file')).toBe(true);
expect(mockedGraphqlClient).toHaveBeenCalledWith(
expect.stringContaining('github_files_check_my_file'),
expect.any(Object),
);
});

it('should return an empty map when no files are provided', async () => {
const url = 'https://github.com/owner/repo';
const files = new Map<string, string>();

const response = {
repository: {},
};
mockedGraphqlClient.mockResolvedValue(response);

const result = await githubClient.checkFilesExist(url, repository, files);

expect(result.size).toBe(0);
});

it('should throw error when GitHub integration for URL is missing', async () => {
const unknownUrl = 'https://unknown-host/owner/repo';
const files = new Map<string, string>([
['github.files_check.readme', 'README.md'],
]);

await expect(
githubClient.checkFilesExist(unknownUrl, repository, files),
).rejects.toThrow(`Missing GitHub integration for '${unknownUrl}'`);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,56 @@ export class GithubClient {

return response.repository.pullRequests.totalCount;
}

/**
* Sanitize a string to be a valid GraphQL alias.
* eg. "github.files_check.readme-correct" -> "github_files_check_readme_correct"
*/
private sanitizeGraphQLAlias(alias: string): string {
return alias.replace(/[^_0-9A-Za-z]/g, '_');
}

async checkFilesExist(
url: string,
repository: GithubRepository,
files: Map<string, string>,
): Promise<Map<string, boolean>> {
const octokit = await this.getOctokitClient(url);

// Map sanitized aliases back to original metric IDs
const aliasToMetricId = new Map<string, string>();
for (const [metricId] of files) {
const sanitizedAlias = this.sanitizeGraphQLAlias(metricId);
aliasToMetricId.set(sanitizedAlias, metricId);
}

const fileChecks = Array.from(files.entries())
.map(([metricId, path]) => {
const sanitizedAlias = this.sanitizeGraphQLAlias(metricId);
return `${sanitizedAlias}: object(expression: "HEAD:${path}") { id }`;
})
.join('\n');
Comment thread
djanickova marked this conversation as resolved.
Outdated

const query = `
query checkFilesExist($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
${fileChecks}
}
}
`;

const response = await octokit<{
repository: Record<string, { id: string } | null>;
}>(query, {
owner: repository.owner,
repo: repository.repo,
});

// Map results back to original metric IDs
const results = new Map<string, boolean>();
for (const [sanitizedAlias, metricId] of aliasToMetricId) {
results.set(metricId, response.repository[sanitizedAlias] !== null);
}
return results;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,18 @@ export type GithubRepository = {
owner: string;
repo: string;
};

/**
* Single file to check
*/
export type GithubFile = {
id: string;
path: string;
};

/**
* Configuration for a file existence check
*/
export type GithubFilesConfig = {
files: GithubFile[];
};
Loading