Files
gstack/bin/gstack-factory-scorecard
T
Garry Tanandcapy-ai[bot] 8663c3d25f v1.91.252.0 feat(retro): factory scorecard (lead time, gate cost, red-train and flaky rates) and the recommended-harness adoption doc (#3471)
* 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>
2026-10-26 17:50:02 +00:00

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`);