diff --git a/bin/gstack-factory-scorecard b/bin/gstack-factory-scorecard index 0c2e166a8..e488f7a37 100755 --- a/bin/gstack-factory-scorecard +++ b/bin/gstack-factory-scorecard @@ -32,7 +32,7 @@ import { function usage(code: number): never { const lines = fs.readFileSync(new URL(import.meta.url), 'utf8').split('\n'); - const text = lines.slice(1, lines.indexOf(' */')).map(l => l.replace(/^\/?\*+\s?/, '')).join('\n'); + const text = lines.slice(2, lines.indexOf(' */')).map(l => l.replace(/^ ?\* ?/, '')).join('\n'); (code ? console.error : console.log)(text); process.exit(code); } @@ -56,6 +56,7 @@ 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; @@ -64,8 +65,9 @@ if (!Number.isInteger(maxArtifactRuns) || maxArtifactRuns < 0) { console.error(' function originRepo(): string { const origin = spawnSync('git', ['remote', 'get-url', 'origin'], { encoding: 'utf8', timeout: 10_000 }); - const slug = /[:/]([^/:]+\/[^/]+?)(?:\.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 owner/name'); usage(2); } + 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; } @@ -97,8 +99,11 @@ 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'); -fs.appendFileSync(trendFile, (fs.existsSync(trendFile) ? '' : csvHeader + '\n') + csvRows.join('\n')); +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`); diff --git a/docs/recommended-harness.md b/docs/recommended-harness.md index e41e80735..a2fa33db6 100644 --- a/docs/recommended-harness.md +++ b/docs/recommended-harness.md @@ -257,8 +257,8 @@ gstack-factory-scorecard --repo owner/name --since-days 7 \ [--pass-cap 3] [--out-dir .context/factory-scorecard] ``` -`--repo` defaults to the origin remote's GitHub owner/name and is required -without one. Each run writes this window's `scorecard.md` and `scorecard.csv` +`--repo` defaults to the origin remote's owner/name on github.com (or +`GH_HOST`) and is required without one. Each run writes this window's `scorecard.md` and `scorecard.csv` to `--out-dir` and appends the same rows to `trend.csv` there, so a weekly retro accumulates its own trend without renaming files. @@ -301,9 +301,10 @@ gate runs, artifacts read for the newest 60): Read the gap honestly: lead time and throughput are where they should be, the gate is green and its cost is mostly known, and flakiness is the line that is not. The daily pass exists for exactly that line; the scorecard's job is to -show next week whether it moved. The inventory lane's baseline CSV, when it -lands in the Captain's Drive, supersedes these numbers for trend tracking; -from then on `trend.csv` in `--out-dir` carries the weeks. +show next week whether it moved. The inventory lane's per-PR baseline CSV in +the Captain's Drive (142 merged PRs over the eleven days before this window +closed) holds the longer history behind these numbers; from here on +`trend.csv` in `--out-dir` carries the weeks. ## What is Capy-specific and what is not diff --git a/lib/factory-scorecard.ts b/lib/factory-scorecard.ts index 76ca821d5..afdc6504e 100644 --- a/lib/factory-scorecard.ts +++ b/lib/factory-scorecard.ts @@ -167,8 +167,10 @@ export function readMergedPrs(source: ScorecardSource, o: ScorecardOptions, sinc export function passCount(text: string, marker: RegExp): number | null { let max: number | null = null; for (const m of text.matchAll(new RegExp(marker.source, marker.flags.includes('g') ? marker.flags : marker.flags + 'g'))) { - const n = Number(m.slice(1).find(g => g !== undefined)); - if (Number.isFinite(n) && (max === null || n > max)) max = n; + const digits = m.slice(1).find(g => g !== undefined && /^\d+$/.test(g)); + if (digits === undefined) continue; + const n = Number(digits); + if (max === null || n > max) max = n; } return max; } diff --git a/retro/SKILL.md b/retro/SKILL.md index eafa4c0a3..4022e8a35 100644 --- a/retro/SKILL.md +++ b/retro/SKILL.md @@ -683,7 +683,7 @@ gh auth status >/dev/null 2>&1 && "$_FS" --base "" --since-days "/dev/null 2>&1 && "$_FS" --base "" --since-days " { expect(readComments(source, 'acme/widgets', 102, markers(/READY #\d+/m, '2026-10-25T12:00:00Z')).readyAt).toBeNull(); expect(readComments(source, 'acme/widgets', 102, markers(/READY #\d+/m)).readyAt).toBe('2026-10-26T01:00:00Z'); expect(passCount('"passes": 4 then passes=1 then REVIEW_PASSES: 2', new RegExp(DEFAULTS.passMarker, 'm'))).toBe(4); + expect(passCount('reviews: 3 and passes=2', /(passes|reviews)[=: ]+(\d+)/m)).toBe(3); expect(passCount('no count here', new RegExp(DEFAULTS.passMarker, 'm'))).toBeNull(); }); @@ -312,7 +313,10 @@ describe('factory-scorecard: the bin', () => { expect(trend.length).toBe(1 + Object.keys(BENCHMARKS).length); const again = run(['--repo', 'acme/widgets', '--fixtures', FIXTURE, '--now', '2026-10-27T12:00:00Z'], cwd); expect(again.status, again.stderr).toBe(0); - expect(fs.readFileSync(path.join(cwd, '.context', 'factory-scorecard', 'trend.csv'), 'utf8').split('\n').filter(Boolean).length).toBe(1 + 2 * Object.keys(BENCHMARKS).length); + const trendLines = () => fs.readFileSync(path.join(cwd, '.context', 'factory-scorecard', 'trend.csv'), 'utf8').split('\n').filter(Boolean).length; + expect(trendLines()).toBe(1 + 2 * Object.keys(BENCHMARKS).length); + expect(run(['--repo', 'acme/widgets', '--fixtures', FIXTURE, '--now', '2026-10-27T12:00:00Z'], cwd).status).toBe(0); + expect(trendLines()).toBe(1 + 2 * Object.keys(BENCHMARKS).length); expect(nested.stdout).toContain(`SCORECARD_TREND: ${path.join('.context', 'factory-scorecard', 'trend.csv')}`); }); @@ -333,12 +337,22 @@ describe('factory-scorecard: the bin', () => { const noRepo = run(['--fixtures', FIXTURE], cwd); expect(noRepo.status).toBe(2); expect(noRepo.stderr).toContain('--repo owner/name is required'); + const local = scratch(); + spawnSync('git', ['init', '-q', local]); + spawnSync('git', ['-C', local, 'remote', 'add', 'origin', '/srv/git/repo.git']); + expect(run(['--fixtures', FIXTURE], local).stderr).toContain('--repo owner/name is required'); + spawnSync('git', ['-C', local, 'remote', 'set-url', 'origin', 'git@ghe.example.test:acme/widgets.git']); + const ghe = spawnSync(BIN, ['--fixtures', FIXTURE, '--now', '2026-10-26T12:00:00Z'], { cwd: local, encoding: 'utf8', timeout: 60_000, env: { ...process.env, HOME: local, GH_HOST: 'ghe.example.test' } }); + expect(ghe.status, ghe.stderr).toBe(0); + expect(ghe.stdout).toContain('| Merged PRs | 3 (crew 2, outside 1) |'); + expect(run(['--pass-marker', 'passes', '--fixtures', FIXTURE], cwd).stderr).toContain('--pass-marker needs a capture group'); const stray = run(['stray'], cwd); expect(stray.status).toBe(2); expect(stray.stderr).toContain('unexpected argument: stray'); const help = run(['--help'], cwd); expect(help.status).toBe(0); - expect(help.stdout).toContain('gstack-factory-scorecard'); + expect(help.stdout).toContain('gstack-factory-scorecard [--repo owner/name]'); + expect(help.stdout).not.toContain(' * '); }); test('the retro template runs the bin from the runtime root and reads its table', () => {