diff --git a/.github/labels.conf b/.github/labels.conf new file mode 100644 index 0000000..dced10a --- /dev/null +++ b/.github/labels.conf @@ -0,0 +1,7 @@ +panel=codex-bot-andresmgsl glm-bot-andresmgsl cluade-bot-andresmgsl kimi-bot-andresmgsl +triage-actors=cluade-bot-andresmgsl +scope:cli|C5DEF5|src/ — the command surface (cli.js, api.js, config.js) +scope:packaging|C5DEF5|scripts/ and the release workflow — deb build, registry publish, apt install path +scope:manifests|C5DEF5|manifests/ — the fleet repo registry data +scope:ci|C5DEF5|.forgejo/workflows/ — the test and label gates +scope:docs|C5DEF5|README and docs/ — the prose contract diff --git a/scripts/check-governance.js b/scripts/check-governance.js new file mode 100644 index 0000000..22a4951 --- /dev/null +++ b/scripts/check-governance.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node + +const fs = require('node:fs'); +const path = require('node:path'); + +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 }; +} + +async function validateIdentities(apiUrl, identities) { + for (const login of identities) { + const endpoint = `${apiUrl.replace(/\/$/, '')}/users/${encodeURIComponent(login)}`; + let response; + try { + response = await fetch(endpoint); + } 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); + 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; +}); diff --git a/test/governance.test.js b/test/governance.test.js new file mode 100644 index 0000000..1b97705 --- /dev/null +++ b/test/governance.test.js @@ -0,0 +1,97 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { spawn } = require('node:child_process'); +const fs = require('node:fs'); +const http = require('node:http'); +const os = require('node:os'); +const path = require('node:path'); + +const SCRIPT = path.join(__dirname, '..', 'scripts', 'check-governance.js'); +const REPOSITORY_CONFIG = path.join(__dirname, '..', '.github', 'labels.conf'); +const cleanups = []; +process.on('exit', () => { + for (const dir of cleanups) fs.rmSync(dir, { recursive: true, force: true }); +}); + +function writeConfig(contents) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-governance-test-')); + cleanups.push(dir); + const config = path.join(dir, 'labels.conf'); + fs.writeFileSync(config, contents); + return config; +} + +function runValidator(config, apiUrl) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [SCRIPT, '--config', config, '--api-url', apiUrl], { + encoding: 'utf8', + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.on('close', (status) => resolve({ status, stdout, stderr })); + }); +} + +async function withIdentityServer(logins, callback) { + const server = http.createServer((request, response) => { + const login = decodeURIComponent(request.url.replace('/api/v1/users/', '')); + response.writeHead(logins.has(login) ? 200 : 404, { 'content-type': 'application/json' }); + response.end(JSON.stringify(logins.has(login) ? { login } : { message: 'not found' })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); + try { + await callback(`http://127.0.0.1:${port}/api/v1`); + } finally { + await new Promise((resolve) => server.close(resolve)); + } +} + +const validConfig = [ + 'panel=codex-bot-andresmgsl glm-bot-andresmgsl cluade-bot-andresmgsl kimi-bot-andresmgsl', + 'triage-actors=cluade-bot-andresmgsl', + 'scope:cli|C5DEF5|src/ — the command surface (cli.js, api.js, config.js)', + 'scope:packaging|C5DEF5|scripts/ and the release workflow — deb build, registry publish, apt install path', + 'scope:manifests|C5DEF5|manifests/ — the fleet repo registry data', + 'scope:ci|C5DEF5|.forgejo/workflows/ — the test and label gates', + 'scope:docs|C5DEF5|README and docs/ — the prose contract', +].join('\n'); + +test('governance validator accepts the configured roster when every identity resolves', async () => { + const config = writeConfig(`${validConfig}\n`); + const logins = new Set(['codex-bot-andresmgsl', 'glm-bot-andresmgsl', 'cluade-bot-andresmgsl', 'kimi-bot-andresmgsl']); + await withIdentityServer(logins, async (apiUrl) => { + const result = await runValidator(config, apiUrl); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /4 identities resolved; 5 scope rows valid/); + }); +}); + +test('governance validator fails when a roster identity does not resolve', async () => { + const config = writeConfig(`${validConfig.replace('kimi-bot-andresmgsl', 'kimi-bto-andresmgsl')}\n`); + const logins = new Set(['codex-bot-andresmgsl', 'glm-bot-andresmgsl', 'cluade-bot-andresmgsl', 'kimi-bot-andresmgsl']); + await withIdentityServer(logins, async (apiUrl) => { + const result = await runValidator(config, apiUrl); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /kimi-bto-andresmgsl.*HTTP 404/); + }); +}); + +test('governance validator rejects malformed scope rows before identity requests', async () => { + const config = writeConfig(`${validConfig.replace('|C5DEF5|', '|not-a-color|')}\n`); + const result = await runValidator(config, 'http://127.0.0.1:1/api/v1'); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /malformed label row/); + assert.doesNotMatch(result.stderr, /fetch failed/); +}); + +test('repository governance config resolves the four-member panel and five scopes', async () => { + const logins = new Set(['codex-bot-andresmgsl', 'glm-bot-andresmgsl', 'cluade-bot-andresmgsl', 'kimi-bot-andresmgsl']); + await withIdentityServer(logins, async (apiUrl) => { + const result = await runValidator(REPOSITORY_CONFIG, apiUrl); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /4 identities resolved; 5 scope rows valid/); + }); +});