mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 14:34:42 +01:00
* fix(scorecard): --now bounds every windowed line; recommended-harness week is 83 PRs (GST-135) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(scorecard): page-capped reads report unavailable, releases page to the window, one instant comparison for runs Pre-landing review fixes for GST-135: readMergedPrs, readGateRuns and readReleases throw past MAX_PAGES instead of returning a partial count; readReleases pages until it reaches the window start; listWorkflowRuns compares both bounds with Date.parse and skips unparsable timestamps; tests for a sub-second window, releases behind a full page and the PR page cap; doc wording on the red-train row and the unverified 142-PR baseline removed. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(harness): document the scorecard's --now window end Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(scorecard): a numeric timestamp in the inWindow control is a parsed date, not a bare epoch literal Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(scorecard): releases read every page; flaky row counts records across cases GitHub lists releases by creation, so stopping on a page's oldest publication could miss a draft published inside the window. Read every page (the page cap still marks the line unavailable). Pin the gate-run and release page caps with a test. The harness doc's flaky row now says 17 paid flake records across 11 cases instead of 17 cases. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.333.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>
114 lines
6.5 KiB
TypeScript
Executable File
114 lines
6.5 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>]
|
|
*
|
|
* 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<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`);
|