mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 07:18:03 +01:00
* feat(retro): factory scorecard bin (lead time, gate cost, red-train and flaky rates) and the recommended-harness adoption doc Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(scorecard): register the recorded gh fixture with its free consumer Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(scorecard): cover --gate-event any, an empty window, a failed PR read and a bad --now Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Review pass 1 (core checklist, testing, maintainability, security, performance, simplification, state-machines, red team) over the whole diff: - the flaky line and cost-per-merge depend on every input they read, so a failed free-run or PR read prints unavailable (...) on its half instead of 0 - a PR verdict-comment cost backs one gate run per PR; a READY comment after the merge is ignored - one unreadable artifact leaves its run without cost or flake data instead of dropping the gate section - --repo defaults to the origin owner/name and is required without one (no built-in repository) - --max-artifact-runs, --pass-cap and --pass-marker are validated; --csv creates its directory - one artifact listing per gate run; PR comments are paged; issues use the exact window instant - every run appends its rows to trend.csv in --out-dir - new row: review passes per PR (median, p90, PRs at the cap) from the ship receipt or a comment marker - /retro: report format gains the Factory scorecard slot; the Step 7.5 fence quotes its placeholder - tests for each; <window-days> joins the placeholder allowlist Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: review pass 2 (delta): usage text, pass-marker capture group, GitHub-host origin parsing, idempotent trend.csv - usage() strips the header comment decoration again; --help text asserted - passCount takes the first all-digit capture group; --pass-marker without a capture group exits 2 - --repo falls back to the origin remote only on github.com or GH_HOST; a local-path origin exits 2 - trend.csv keeps one row set per repo and window (a rerun replaces its rows) and always carries its header - /retro Step 7.5 points at SCORECARD_TREND for the weeks; the doc cites the per-PR baseline CSV Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(host-conformance): delete a named section in the C3 broken-link case The case removed the first .md readdirSync returned under gstack-qa/sections. qa-patterns.md and system-functional.md are referenced only in table form, which setup --status does not count as section links, so on an ext4 directory order that listed one of them first the deleted file broke nothing and --status exited 0: deterministic red on remote-gate VMs, green on Actions. Delete scope.md, which every host's rendered SKILL.md reads. Closes #3479 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.252.0 stamp Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
112 lines
6.4 KiB
TypeScript
Executable File
112 lines
6.4 KiB
TypeScript
Executable File
#!/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 <regex>]
|
|
* [--pass-marker <regex>] [--pass-cap 3] [--max-artifact-runs 60]
|
|
* [--out-dir <dir>] [--csv <file>] [--json] [--cache-dir <dir>]
|
|
* [--fixtures <recorded gh responses.json>] [--now <iso>]
|
|
*
|
|
* --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<string, string>();
|
|
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`);
|