#!/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; });