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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-26 14:32:07 +00:00
1 parent 2219c78ebe
commit cacc6003ea
6 files changed
+38 -16

No files matched your search

+10 -5
View File
@@ -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`);
+6 -5
View File
@@ -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
+4 -2
View File
@@ -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;
}
+1 -1
View File
@@ -683,7 +683,7 @@ gh auth status >/dev/null 2>&1 && "$_FS" --base "<default>" --since-days "<windo
|| echo "FACTORY_SCORECARD: unavailable"
```
Paste the table under **Factory scorecard**, third column included (what one of the best factories shows on that line). Keep any `unavailable (...)` wording; never write 0 for it. `SCORECARD_CSV:` is the week's trend row. `docs/recommended-harness.md#the-factory-scorecard` explains each line and the `--crew`, `--gate-workflow`, `--free-workflow`, `--train-prefix` knobs.
Paste the table under **Factory scorecard**, third column included (what one of the best factories shows on that line). Keep any `unavailable (...)` wording; never write 0 for it. `SCORECARD_TREND:` accumulates the weeks. `docs/recommended-harness.md#the-factory-scorecard` explains each line and the `--crew`, `--gate-workflow`, `--free-workflow`, `--train-prefix` knobs.
### Step 8: Focus Score + Ship of the Week
+1 -1
View File
@@ -279,7 +279,7 @@ gh auth status >/dev/null 2>&1 && "$_FS" --base "<default>" --since-days "<windo
|| echo "FACTORY_SCORECARD: unavailable"
```
Paste the table under **Factory scorecard**, third column included (what one of the best factories shows on that line). Keep any `unavailable (...)` wording; never write 0 for it. `SCORECARD_CSV:` is the week's trend row. `docs/recommended-harness.md#the-factory-scorecard` explains each line and the `--crew`, `--gate-workflow`, `--free-workflow`, `--train-prefix` knobs.
Paste the table under **Factory scorecard**, third column included (what one of the best factories shows on that line). Keep any `unavailable (...)` wording; never write 0 for it. `SCORECARD_TREND:` accumulates the weeks. `docs/recommended-harness.md#the-factory-scorecard` explains each line and the `--crew`, `--gate-workflow`, `--free-workflow`, `--train-prefix` knobs.
### Step 8: Focus Score + Ship of the Week
+16 -2
View File
@@ -248,6 +248,7 @@ describe('factory-scorecard: pieces', () => {
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', () => {