152 lines
5.8 KiB
JavaScript
152 lines
5.8 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { execFileSync } = require('node:child_process');
|
|
|
|
const UNSCOPED_PATHS = [
|
|
'.gitignore', // Repository plumbing has no product surface.
|
|
'assets/logo-mark.svg', // One legacy brand asset does not justify a scope taxonomy.
|
|
'test/*.test.js', // Tests inherit the scope of the production surface changed beside them.
|
|
];
|
|
|
|
function parseArgs(argv) {
|
|
const options = {
|
|
config: path.join(process.cwd(), '.github', 'labels.conf'),
|
|
apiUrl: process.env.FORGE_API_URL
|
|
|| `${(process.env.FORGE_SERVER_URL || 'https://forgejo.heavyduty.builders').replace(/\/$/, '')}/api/v1`,
|
|
};
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const flag = argv[index];
|
|
if (flag !== '--config' && flag !== '--api-url') {
|
|
throw new Error(`unknown option: ${flag}`);
|
|
}
|
|
const value = argv[index + 1];
|
|
if (!value) throw new Error(`${flag} requires a value`);
|
|
options[flag === '--config' ? 'config' : 'apiUrl'] = value;
|
|
index += 1;
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function parseConfig(contents, filename) {
|
|
let panel;
|
|
let triageActors = [];
|
|
const scopes = [];
|
|
for (const line of contents.split(/\r?\n/)) {
|
|
if (!line) continue;
|
|
if (line.startsWith('panel=')) {
|
|
if (panel) throw new Error(`duplicate panel line in ${filename}`);
|
|
panel = line.slice('panel='.length).trim().split(/\s+/).filter(Boolean);
|
|
if (panel.length === 0) throw new Error(`panel must name at least one reviewer in ${filename}`);
|
|
continue;
|
|
}
|
|
if (line.startsWith('triage-actors=')) {
|
|
triageActors = line.slice('triage-actors='.length).trim().split(/\s+/).filter(Boolean);
|
|
continue;
|
|
}
|
|
const fields = line.split('|');
|
|
if (fields.length !== 3 || !fields[0] || !/^[0-9A-Fa-f]{6}$/.test(fields[1]) || !fields[2]) {
|
|
throw new Error(`malformed label row: ${line} in ${filename}`);
|
|
}
|
|
scopes.push(line);
|
|
}
|
|
if (!panel) throw new Error(`missing panel= line in ${filename}`);
|
|
|
|
const identities = [...new Set([...panel, ...triageActors])];
|
|
for (const login of identities) {
|
|
if (!/^[A-Za-z0-9-]+$/.test(login)) {
|
|
throw new Error(`malformed login in ${filename}: ${login}`);
|
|
}
|
|
}
|
|
return { identities, scopes };
|
|
}
|
|
|
|
function parseLabeler(contents, filename) {
|
|
const mappings = new Map();
|
|
let current;
|
|
for (const line of contents.split(/\r?\n/)) {
|
|
const label = line.match(/^"([^"]+)":$/);
|
|
if (label) {
|
|
current = label[1];
|
|
if (mappings.has(current)) throw new Error(`duplicate scope mapping in ${filename}: ${current}`);
|
|
mappings.set(current, []);
|
|
continue;
|
|
}
|
|
const matcher = line.match(/^\s+- any-glob-to-any-file:\s*(\[[^\n]+\])$/);
|
|
if (!matcher) continue;
|
|
if (!current) throw new Error(`scope matcher has no label in ${filename}`);
|
|
let globs;
|
|
try {
|
|
globs = JSON.parse(matcher[1]);
|
|
} catch {
|
|
throw new Error(`malformed scope matcher for ${current} in ${filename}`);
|
|
}
|
|
if (!Array.isArray(globs) || globs.length === 0 || globs.some((glob) => typeof glob !== 'string')) {
|
|
throw new Error(`malformed scope matcher for ${current} in ${filename}`);
|
|
}
|
|
mappings.get(current).push(...globs);
|
|
}
|
|
for (const [label, globs] of mappings) {
|
|
if (globs.length === 0) throw new Error(`scope mapping has no globs in ${filename}: ${label}`);
|
|
}
|
|
return mappings;
|
|
}
|
|
|
|
function validateScopeNames(scopes, mappings) {
|
|
const configNames = new Set(scopes.map((scope) => scope.split('|', 1)[0]));
|
|
const labelerNames = new Set(mappings.keys());
|
|
const onlyInConfig = [...configNames].filter((name) => !labelerNames.has(name)).sort();
|
|
const onlyInLabeler = [...labelerNames].filter((name) => !configNames.has(name)).sort();
|
|
const errors = [];
|
|
if (onlyInConfig.length > 0) errors.push(`scope names only in labels.conf: ${onlyInConfig.join(', ')}`);
|
|
if (onlyInLabeler.length > 0) errors.push(`scope names only in labeler.yml: ${onlyInLabeler.join(', ')}`);
|
|
if (errors.length > 0) throw new Error(errors.join('; '));
|
|
}
|
|
|
|
function validateTrackedPaths(mappings) {
|
|
let tracked;
|
|
try {
|
|
tracked = execFileSync('git', ['ls-files'], { encoding: 'utf8' }).trim().split('\n').filter(Boolean);
|
|
} catch (error) {
|
|
throw new Error(`cannot enumerate tracked paths: ${error.message}`);
|
|
}
|
|
const globs = [...mappings.values()].flat();
|
|
const uncovered = tracked.filter((filename) => (
|
|
!UNSCOPED_PATHS.some((glob) => path.matchesGlob(filename, glob))
|
|
&& !globs.some((glob) => path.matchesGlob(filename, glob))
|
|
));
|
|
if (uncovered.length > 0) {
|
|
throw new Error(`tracked paths have no scope mapping: ${uncovered.join(', ')}`);
|
|
}
|
|
}
|
|
|
|
async function validateIdentities(apiUrl, identities) {
|
|
for (const login of identities) {
|
|
const endpoint = `${apiUrl.replace(/\/$/, '')}/users/${encodeURIComponent(login)}`;
|
|
let response;
|
|
try {
|
|
response = await fetch(endpoint, { redirect: 'manual' });
|
|
} catch (error) {
|
|
throw new Error(`${login}: fetch failed: ${error.message}`);
|
|
}
|
|
if (response.status !== 200) throw new Error(`${login}: HTTP ${response.status} from ${endpoint}`);
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
const contents = fs.readFileSync(options.config, 'utf8');
|
|
const { identities, scopes } = parseConfig(contents, options.config);
|
|
const labeler = path.join(path.dirname(options.config), 'labeler.yml');
|
|
const mappings = parseLabeler(fs.readFileSync(labeler, 'utf8'), labeler);
|
|
validateScopeNames(scopes, mappings);
|
|
validateTrackedPaths(mappings);
|
|
await validateIdentities(options.apiUrl, identities);
|
|
console.log(`governance: ${identities.length} identities resolved; ${scopes.length} scope rows valid`);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(`governance: ${error.message}`);
|
|
process.exitCode = 1;
|
|
});
|