#!/usr/bin/env -S bun --no-env-file --config=/dev/null /** * gstack-factory-scorecard — one window of a repository's shipping numbers, * read from GitHub through gh: merged PRs (crew vs outside), lead time (created * to merged, READY to merged), gate runs with known cost, red-train rate, flaky * events, issues opened vs closed, releases cut. Markdown table on stdout plus * a CSV; /retro Step 7.5 runs it; docs/recommended-harness.md explains each * line and where gstack's own numbers stand. Read-only: no write, no paid work. * * Usage: * gstack-factory-scorecard [--repo owner/name] [--base main] [--since-days 7] * [--crew login,login] [--gate-workflow evals.yml] [--gate-event workflow_dispatch|any] * [--free-workflow free-tests.yml] [--train-prefix train/] [--ready-marker ] * [--pass-marker ] [--pass-cap 3] [--max-artifact-runs 60] * [--out-dir ] [--csv ] [--json] [--cache-dir ] * [--fixtures ] [--now ] * * The window is [now - since-days, now): --now (default: the current time) is * its end, and every line counts only items inside it. * --repo defaults to the GitHub owner/name of the origin remote; without one, * pass it. Every run writes this window's scorecard.{csv,md} and appends its * rows to trend.csv in --out-dir, so the weeks line up. * Exit 0 with the table even when an input is unavailable (the line says so); * exit 2 on bad usage. Needs gh with read access to the repository unless * --fixtures replays recorded responses. */ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { DEFAULTS, collectScorecard, fixtureSource, ghSource, renderCsv, renderMarkdown, scorecardRows, type FixtureFile, type ScorecardOptions, } from '../lib/factory-scorecard'; function usage(code: number): never { const lines = fs.readFileSync(new URL(import.meta.url), 'utf8').split('\n'); const text = lines.slice(2, lines.indexOf(' */')).map(l => l.replace(/^ ?\* ?/, '')).join('\n'); (code ? console.error : console.log)(text); process.exit(code); } const args = process.argv.slice(2); const flags = new Map(); let json = false; for (let i = 0; i < args.length; i++) { const a = args[i]!; if (a === '--help' || a === '-h') usage(0); if (a === '--json') { json = true; continue; } if (!a.startsWith('--') || i + 1 >= args.length) { console.error(`unexpected argument: ${a}`); usage(2); } flags.set(a.slice(2), args[++i]!); } const sinceDays = Number(flags.get('since-days') ?? DEFAULTS.sinceDays); if (!Number.isFinite(sinceDays) || sinceDays <= 0) { console.error('--since-days must be a positive number'); usage(2); } const now = flags.has('now') ? Date.parse(flags.get('now')!) : Date.now(); if (!Number.isFinite(now)) { console.error('--now must be an ISO-8601 instant'); usage(2); } let readyMarker: RegExp; try { readyMarker = new RegExp(flags.get('ready-marker') ?? DEFAULTS.readyMarker, 'm'); } catch (e: any) { console.error(`--ready-marker: ${e.message}`); usage(2); } let passMarker: RegExp; try { passMarker = new RegExp(flags.get('pass-marker') ?? DEFAULTS.passMarker, 'm'); } catch (e: any) { console.error(`--pass-marker: ${e.message}`); usage(2); } if (new RegExp(passMarker.source + '|').exec('')!.length < 2) { console.error('--pass-marker needs a capture group for the pass count'); usage(2); } const passCap = Number(flags.get('pass-cap') ?? DEFAULTS.passCap); if (!Number.isInteger(passCap) || passCap < 1) { console.error('--pass-cap must be a positive integer'); usage(2); } const gateEvent = flags.get('gate-event') ?? DEFAULTS.gateEvent; const maxArtifactRuns = Number(flags.get('max-artifact-runs') ?? DEFAULTS.maxArtifactRuns); if (!Number.isInteger(maxArtifactRuns) || maxArtifactRuns < 0) { console.error('--max-artifact-runs must be a non-negative integer'); usage(2); } function originRepo(): string { const origin = spawnSync('git', ['remote', 'get-url', 'origin'], { encoding: 'utf8', timeout: 10_000 }); const host = (process.env.GH_HOST || 'github.com').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const slug = new RegExp(`(?:^|[@/])${host}[:/]([^/:]+\\/[^/]+?)(?:\\.git)?\\/?$`).exec((origin.stdout ?? '').trim())?.[1]; if (origin.status !== 0 || !slug) { console.error(`--repo owner/name is required: the origin remote is missing or not a ${process.env.GH_HOST || 'github.com'} owner/name`); usage(2); } return slug; } const options: ScorecardOptions = { repo: flags.get('repo') ?? originRepo(), base: flags.get('base') ?? DEFAULTS.base, now, sinceDays, crew: flags.has('crew') ? flags.get('crew')!.split(',').map(s => s.trim()).filter(Boolean) : null, gateWorkflow: flags.get('gate-workflow') ?? DEFAULTS.gateWorkflow, gateEvent: gateEvent === 'any' ? null : gateEvent, freeWorkflow: flags.get('free-workflow') ?? DEFAULTS.freeWorkflow, trainPrefix: flags.get('train-prefix') ?? DEFAULTS.trainPrefix, readyMarker, passMarker, passCap, maxArtifactRuns, }; const cacheDir = flags.get('cache-dir') ?? path.join(os.tmpdir(), 'gstack-factory-scorecard', options.repo.replace('/', '__')); const source = flags.has('fixtures') ? fixtureSource(JSON.parse(fs.readFileSync(flags.get('fixtures')!, 'utf8')) as FixtureFile) : ghSource(options.repo, cacheDir); const card = collectScorecard(source, options); const rows = scorecardRows(card); const outDir = flags.get('out-dir') ?? path.join('.context', 'factory-scorecard'); fs.mkdirSync(outDir, { recursive: true }); const csvFile = flags.get('csv') ?? path.join(outDir, 'scorecard.csv'); fs.mkdirSync(path.dirname(csvFile), { recursive: true }); fs.writeFileSync(csvFile, renderCsv(card, rows)); fs.writeFileSync(path.join(outDir, 'scorecard.md'), renderMarkdown(card, rows)); if (json) fs.writeFileSync(path.join(outDir, 'scorecard.json'), JSON.stringify({ card, rows }, null, 2) + '\n'); const trendFile = path.join(outDir, 'trend.csv'); const [csvHeader, ...csvRows] = renderCsv(card, rows).split('\n').filter(Boolean); const thisWindow = csvRows[0]!.split(',').slice(0, 3).join(','); const kept = (fs.existsSync(trendFile) ? fs.readFileSync(trendFile, 'utf8').split('\n').filter(Boolean) : []) .filter(line => line !== csvHeader && !line.startsWith(thisWindow + ',')); fs.writeFileSync(trendFile, [csvHeader, ...kept, ...csvRows].join('\n') + '\n'); process.stdout.write(renderMarkdown(card, rows)); process.stdout.write(`SCORECARD_CSV: ${csvFile}\n`); process.stdout.write(`SCORECARD_MD: ${path.join(outDir, 'scorecard.md')}\n`); process.stdout.write(`SCORECARD_TREND: ${trendFile}\n`);