redline metrics
The estate's measurement plane as eight subcommands — collect, dashboard, digest, inbox, baseline, roi, correlate and score-seeds — each with a validated flag surface over the runner it drives.
What this is
The estate's measurement plane, as eight subcommands over the runners in scripts/. These act on an organisation or on its collected telemetry, never on the repository you are standing in — several of them are meaningless in a product repo, and each one says where it runs in its own --help rather than letting you find out the slow way.
How to onboard it
Mostly nothing you run by hand. collect, dashboard, digest and inbox are driven by the scheduled workflows in workflows/, in the metrics repo or the source repo; the CLI exists so the same run is reproducible in a terminal when a scheduled one looks wrong. baseline is run once, by a maintainer, before any of it means anything. What this layer adds over the runners is a front door: every flag has a type, a default and a help line, and an unknown value is refused by name instead of quietly becoming "unknown" inside a figure someone later quotes.
How to use it
$ npx redlinegate@latest metrics # the eight subcommands $ npx redlinegate@latest metrics collect --help # and the flags for one $ npx redlinegate@latest metrics collect --org acme --days 8 $ npx redlinegate@latest metrics dashboard --org acme --data data --out dist $ npx redlinegate@latest metrics digest --org acme --days 7 $ npx redlinegate@latest metrics inbox --org acme --out dist $ npx redlinegate@latest metrics score-seeds --repo acme/pilot-web --pr 12 --history data/seed-scores.jsonl $ npx redlinegate@latest metrics roi --data data --spend-total 4200 --spend-grain org
The flags that change behaviour materially:
collect · dashboard · digest · inbox— The loop. collect pulls review outcomes for merged pull requests across the org with a read-only token; dashboard builds the static page (acted-on rate, trends, seed-recall history, the rule tuning queue); digest builds the weekly Adaptive Card; inbox builds the org-wide prioritised pull request page. Each is also a scheduled workflow — running one here reproduces that run.score-seeds— Scores an automated reviewer against the seeded corpus on a pull request that carries it: recall, precision and attribution. --history appends to a JSONL so recall has a trend rather than a single reading, and --baseline records the first one as the line everything after is compared to. This is the command that tells "no findings" apart from "nothing to find".baseline · roi · correlate— baseline computes the figures every later phase is judged against, once, from a maintainer terminal. roi sets what review cost against what it caught — and refuses to answer a per-repository question with an org-wide spend figure, which is why --spend-grain exists. correlate is explicitly research, not a loop: whether ignoring a finding cost anything later.every flag has an environment variable— The runners under scripts/ are env-configured programs, so each flag maps to a documented variable (--days to DAYS, --data to DATA_DIR). The names are deliberately the same ones the workflows set, so somebody debugging a scheduled run reads one vocabulary and not two.
Expected output
Per subcommand: JSONL telemetry, a static HTML page, an Adaptive Card, or a score. What none of them will do is invent a number — a figure that could not be computed states why instead of defaulting, because a measurement plane that fills gaps with zeros is one that reports a stalled collector as a quiet week.
How to edit it
cli/metrics/options.ts declares the whole flag surface as data — env name, type, default, help, and the accepted values for an enum. Adding a flag is a row in that table, and the tests assert the mapping (that --days 90 becomes DAYS=90, that --days banana is refused by name). The work itself stays in scripts/; this layer only configures and validates.
Run npm test and npm run typecheck before pushing: the command surface is unit-tested against a fake host client, so a behaviour change shows up as a failing assertion rather than as a surprise on someone's repository.
The full file
import { RedlineError } from '../core/errors.ts';
// The flag surface for the estate commands, declared as data.
//
// Why this shape. The runners under `scripts/` are already env-configured
// programs, and they work. What they lack is a front door: no `--help`, no
// validation, and the only way to discover that `MIN_SAMPLE` exists is to read
// the source. So this layer owns configuration — every flag has a type, a
// default and a help line — and hands the runner the environment contract it
// already documents.
//
// Declaring it as data rather than writing a parser per command is what makes it
// testable: that `--days 90` becomes `DAYS=90`, that `--days banana` is refused
// by name, and that a missing required flag says which one, are all assertions
// about this table.
export type OptionType = 'string' | 'number' | 'boolean' | 'path';
export interface OptionSpec {
// The environment variable the runner reads. This is the contract between the
// two layers and is deliberately visible: someone debugging a scheduled run
// sees the same names in the workflow and in the docs.
env: string;
type: OptionType;
help: string;
required?: boolean;
default?: string;
// For an enum-ish string, the values it accepts. A typo in a value is the
// failure this catches: today `SPEND_GRAIN=per-seat` silently becomes
// "unknown" and the number it produces is quietly less trustworthy.
values?: string[];
}
export interface CommandSpec {
summary: string;
// This runner parses its own argv rather than reading the environment. The
// flags below are still validated and still generate its --help; they are
// forwarded as arguments instead of exported as variables. Without this the
// command would set variables the runner ignores and then fail with the
// runner's own usage error, which is exactly the confusion it exists to remove.
argv?: boolean;
// Where this runs, said in the help text. Several of these are org
// infrastructure and are meaningless in a product repository, and a command
// that does not say so wastes somebody's afternoon.
runsIn: string;
script: string;
options: Record<string, OptionSpec>;
}
const DATA: OptionSpec = {
env: 'DATA_DIR',
type: 'path',
help: 'directory of collected telemetry',
default: 'data',
};
const ORG: OptionSpec = { env: 'ORG', type: 'string', help: 'the GitHub organisation', required: true };
const TOKEN: OptionSpec = {
env: 'GH_TOKEN',
type: 'string',
help: 'a token with org read access (or set GH_TOKEN in the environment)',
};
export const METRICS_COMMANDS: Record<string, CommandSpec> = {
collect: {
summary: 'pull review outcomes for merged pull requests across the org',
runsIn: 'the metrics repo, on a schedule',
script: 'scripts/collect-telemetry.mjs',
options: {
org: ORG,
token: TOKEN,
days: { env: 'DAYS', type: 'number', help: 'how far back to look', default: '8' },
since: { env: 'SINCE', type: 'string', help: 'YYYY-MM-DD, instead of --days' },
out: { env: 'OUT', type: 'path', help: 'where to write the monthly JSONL', default: 'data' },
'dry-run': { env: 'DRY_RUN', type: 'boolean', help: 'print a sample and write nothing' },
'skip-sarif': { env: 'SKIP_SARIF', type: 'boolean', help: 'do not ingest code-scanning alerts' },
},
},
dashboard: {
summary: 'build the telemetry dashboard page',
runsIn: 'the metrics repo, or any checkout of its data/',
script: 'scripts/build-dashboard.mjs',
options: {
org: ORG,
data: DATA,
days: { env: 'DAYS', type: 'number', help: 'window in days', default: '90' },
out: { env: 'OUT', type: 'path', help: 'output directory', default: 'dist' },
registry: { env: 'REGISTRY', type: 'path', help: 'the register, for coverage and the ladder', default: 'registry.json' },
onboarded: { env: 'ONBOARDED', type: 'number', help: 'onboarded repo count, if not read from the register' },
'seed-scores': { env: 'SEED_SCORES', type: 'path', help: 'seed score history', default: 'data/seed-scores.jsonl' },
'standards-version': { env: 'STANDARDS_VERSION', type: 'string', help: 'version to display' },
},
},
digest: {
summary: 'build the weekly stakeholder digest as an Adaptive Card',
runsIn: 'the metrics repo, weekly',
script: 'scripts/build-digest.mjs',
options: {
org: ORG,
data: DATA,
days: { env: 'DAYS', type: 'number', help: 'window in days', default: '7' },
out: { env: 'OUT', type: 'path', help: 'write the card here instead of stdout' },
'open-prs': { env: 'OPEN_PRS', type: 'number', help: 'open PR count, from the caller' },
'stale-prs': { env: 'STALE_PRS', type: 'number', help: 'stale PR count, from the caller' },
'seed-scores': { env: 'SEED_SCORES', type: 'path', help: 'seed score history, for the recall line' },
'dashboard-url': { env: 'DASHBOARD_URL', type: 'string', help: 'linked from the card' },
},
},
inbox: {
summary: 'build the org-wide prioritised pull request inbox',
runsIn: 'the source repo, on a schedule',
script: 'scripts/build-inbox.mjs',
options: {
org: ORG,
token: TOKEN,
out: { env: 'OUT', type: 'path', help: 'output directory', default: 'dist' },
'max-pages': { env: 'MAX_PAGES', type: 'number', help: 'search pages to walk', default: '10' },
},
},
baseline: {
summary: 'compute the baseline every roadmap phase is measured against',
runsIn: 'a maintainer terminal, once, with org credentials',
script: 'scripts/build-baseline.mjs',
options: {
data: DATA,
org: { env: 'ORG', type: 'string', help: 'the org, to survey Redline\'s own PRs and SARIF producers' },
token: TOKEN,
registry: { env: 'REGISTRY', type: 'path', help: 'the derived register', default: 'registry.json' },
days: { env: 'DAYS', type: 'number', help: 'window in days', default: '90' },
out: { env: 'OUT', type: 'path', help: 'where to write the JSON', default: 'baseline.json' },
'spend-total': { env: 'SPEND_TOTAL', type: 'number', help: 'AI spend, from the vendor\'s own usage reporting' },
'spend-currency': { env: 'SPEND_CURRENCY', type: 'string', help: 'currency of --spend-total', default: 'USD' },
'spend-grain': {
env: 'SPEND_GRAIN',
type: 'string',
help: 'whether the spend figure is per repo or org-wide',
default: 'org',
values: ['repo', 'org', 'unknown'],
},
},
},
roi: {
summary: 'build the page that says what review cost against what it caught',
runsIn: 'the metrics repo, or any checkout of its data/',
script: 'scripts/build-roi.mjs',
options: {
data: DATA,
days: { env: 'DAYS', type: 'number', help: 'window in days', default: '90' },
out: { env: 'OUT', type: 'path', help: 'JSON output', default: 'roi.json' },
html: { env: 'HTML', type: 'path', help: 'the readable page', default: 'roi.html' },
'spend-total': { env: 'SPEND_TOTAL', type: 'number', help: 'AI spend for the window' },
'spend-currency': { env: 'SPEND_CURRENCY', type: 'string', help: 'currency of --spend-total', default: 'USD' },
'spend-grain': {
env: 'SPEND_GRAIN',
type: 'string',
help: 'per repo or org-wide — an org figure will not answer a per-repo question',
default: 'org',
values: ['repo', 'org', 'unknown'],
},
'spend-source': { env: 'SPEND_SOURCE', type: 'string', help: 'where the figure came from, recorded with it' },
},
},
correlate: {
summary: 'research: whether ignoring a finding cost anything',
runsIn: 'the metrics repo. It is an experiment, not a loop',
script: 'scripts/build-correlation.mjs',
options: {
data: DATA,
days: { env: 'DAYS', type: 'number', help: 'how much history to read', default: '180' },
'window-days': { env: 'WINDOW_DAYS', type: 'number', help: 'how long after a merge a revert still counts', default: '30' },
'min-sample': {
env: 'MIN_SAMPLE',
type: 'number',
help: 'ignored findings a rule needs before any rate is reported',
default: '10',
},
out: { env: 'OUT', type: 'path', help: 'where to write the JSON', default: 'correlation.json' },
},
},
'score-seeds': {
summary: 'score an automated reviewer against the seeded corpus',
runsIn: 'anywhere, against a pull request that carries the corpus',
script: 'scripts/score-seeds.mjs',
argv: true,
options: {
repo: { env: 'REPO', type: 'string', help: 'owner/name of the pilot repository', required: true },
pr: { env: 'PR', type: 'number', help: 'the pull request number', required: true },
token: TOKEN,
json: { env: 'JSON_OUT', type: 'boolean', help: 'machine-readable output' },
window: { env: 'WINDOW', type: 'number', help: 'lines around a marker a finding may land on', default: '4' },
history: { env: 'HISTORY', type: 'path', help: 'append the score to this JSONL' },
baseline: { env: 'BASELINE', type: 'boolean', help: 'record as a baseline rather than a run' },
},
},
};
export const REGISTRY_COMMAND: CommandSpec = {
summary: 'derive the register of onboarded repositories by walking the org',
runsIn: 'the source repo, nightly',
script: 'scripts/build-registry.mjs',
options: {
org: ORG,
token: TOKEN,
source: {
env: 'SOURCE',
type: 'string',
help: 'owner/name of this repo, recorded so a consumer knows which estate the register describes',
required: true,
},
out: { env: 'OUT', type: 'path', help: 'where to write it', default: 'registry.json' },
},
};
/**
* Turn parsed flags into the environment the runner reads.
*
* Every refusal here is one that used to be silent. An unset required option was
* a runner throwing halfway through; a non-numeric `--days` became `NaN` and
* produced an empty window; a mistyped `--spend-grain` became "unknown" and made
* the resulting number quietly less trustworthy than it looked.
*/
export function resolveEnv(
spec: CommandSpec,
flags: Record<string, string | boolean | undefined>,
env: NodeJS.ProcessEnv,
name: string
): Record<string, string> {
const resolved: Record<string, string> = {};
for (const [flag, option] of Object.entries(spec.options)) {
const raw = flags[flag];
if (option.type === 'boolean') {
if (raw === true) resolved[option.env] = '1';
continue;
}
// Precedence: the flag, then the environment, then the default. The
// environment stays honoured so a scheduled job can keep using a secret
// without putting a token on a command line, where it lands in shell history
// and in the process table.
const value = raw !== undefined ? String(raw) : (env[option.env] ?? option.default);
if (value === undefined || value === '') {
if (option.required) {
throw new RedlineError(
'usage',
`redline ${name} needs --${flag} (${option.help})`,
`or set ${option.env} in the environment`
);
}
continue;
}
if (option.type === 'number' && !Number.isFinite(Number(value))) {
throw new RedlineError('usage', `--${flag} must be a number, not "${value}"`);
}
if (option.values && !option.values.includes(value)) {
throw new RedlineError(
'usage',
`--${flag} must be one of ${option.values.join(', ')}, not "${value}"`
);
}
resolved[option.env] = value;
}
return resolved;
}
/** The help for one command, generated from the same table that validates it. */
export function helpFor(name: string, spec: CommandSpec): string {
const left = (flag: string, option: OptionSpec): string =>
` --${flag}${option.type === 'boolean' ? '' : ` <${option.type}>`}`;
// Sized to the widest flag in THIS command, not to a constant: a fixed column
// collides the moment a longer flag is added, and a help screen whose columns
// run together reads as unmaintained.
const width =
Math.max(...Object.entries(spec.options).map(([f, o]) => left(f, o).length)) + 2;
const flags = Object.entries(spec.options).map(([flag, option]) => {
const suffix = option.required
? ' (required)'
: option.default
? ` (default: ${option.default})`
: '';
return left(flag, option).padEnd(width) + `${option.help}${suffix}`;
});
return [
`redline ${name} — ${spec.summary}`,
'',
`Runs in: ${spec.runsIn}.`,
'',
...flags,
'',
'Every option can also be set as an environment variable — see the names in the docs.',
].join('\n');
}
/**
* The same validated flags, as argv, for a runner that parses its own.
*
* `--token` is deliberately never forwarded: it is a credential, and a command
* line is visible in the process table and lands in shell history. It travels as
* an environment variable, which is where the runner reads it from anyway.
*/
export function resolveArgv(
spec: CommandSpec,
flags: Record<string, string | boolean | undefined>,
env: NodeJS.ProcessEnv,
name: string
): string[] {
const resolvedEnv = resolveEnv(spec, flags, env, name);
const argv: string[] = [];
for (const [flag, option] of Object.entries(spec.options)) {
if (option.env === 'GH_TOKEN') continue;
const value = resolvedEnv[option.env];
if (value === undefined) continue;
if (option.type === 'boolean') argv.push(`--${flag}`);
else argv.push(`--${flag}`, value);
}
return argv;
}