Files
houseplan-card/scripts/process-metrics.mjs
T
Claude b7dbc21c42 fix(process-metrics): task volume, merge return reasons and spec drafts (#752)
The weekly report disagreed with its own definitions (#728) and with the
pipeline texts that appeared after it (#705, #723, #729).

Volume. A task's volume counted documentation, so the archive move of #682
weighed 333 060 lines and #680/#681 thousands, all landing in the "> 1000"
bucket and shifting the cohort medians; and every commit with a Release:
trailer was dropped, including the task's own golden acceptance and test
re-pinning commits. Volume is now the +/- of class A and B files only.
Only beta commits are left out: the beta or release candidate (Release:
trailer plus the candidate subject or a changed bundle, bundle-policy.mjs,
drops every Release: commit, so it is not reused. A task whose commits
touch only docs/reviews/** has no volume and is no longer read as
infrastructure: the pipeline writes those documents, not the task.

Return reasons. Every non-merge outcome of merge-candidate.mjs fell to
"unknown". merge-candidate.mjs now exports a sign for the heading of each
outcome comment (OUTCOME_SIGNS; the step-failure text moved into
commentFor as 'error', byte for byte), and a test on the templates
themselves holds every case to its own sign. The report maps merge-stage
outcomes after a green verdict to "merge" and a push refused while
rebasing before review to the new "push-refused" reason.

Spec drafts. A new section after "По трекам" counts the S4-spec-review
epochs on the ask track for tasks whose first S5-ready falls in the
window, the epochs with a "Черновик:" comment (§7.2) and whether the draft
went to S5 or was thrown at S3, Spec-Draft: commits in dev for the window,
and S5 -> S7 per track for tasks with and without a draft, "мало данных"
under three. The snapshot also reads timelines of tasks in S5-S7.

Three #728 assertions pinned the old behaviour (a Release: fixture without
the candidate subject, and the rebase workflow refusal read as unknown);
they now expect the new definitions.

Issue: #752
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 18:21:30 +03:00

1370 lines
78 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// #637: еженедельный замер процесса одним скриптом.
//
// Аудит 22.09 собирал эти цифры руками через API за час: раунды ревью на
// issue, время S→S7→S8, прогоны Validate по исходам, минуты конвейера в S7,
// доля мутантов. Без регулярного замера решения об ускорении (#620, #636)
// нельзя проверить. Здесь — чистые функции над снимками GitHub (issue с
// таймлайном меток, прогоны Actions, имена документов ревью) и тонкий CLI на
// `gh`. Скрипт ничего не пишет в репозиторий и в issue: результат — Markdown в
// stdout/файл; куда его класть, решает workflow (`process-metrics.yml`:
// step summary + artifact, опционально комментарий в issue-журнал).
//
//
// #728: эффект процесса по трекам — трек на момент события, отрезки времени
// (работа, ожидание, переделка), возвраты с причинами, находки пакетного ревью
// ship, job-минуты по стадиям, токены и сравнение сопоставимых задач до и после
// перехода на треки. Определения — в ТЗ #728 и в заголовках разделов отчёта.
// Отчёт только читает: признаки конвейера импортируются, а не копируются, где
// у конвейера есть константа; где её нет (`validate-red`/`conflict`), копию
// текста держит контрактный тест на шаблонах `_process.yml`.
//
// #761: токены — только за окно отчёта: документ ревью входит в неделю, если
// коммит, добавивший его в `dev`, попал в окно (перенос в архив — не добавление).
// #752: объём задачи — `+/−` строк классов A и B (продукт, тесты, инструменты)
// в её коммитах с трейлером `Issue: #NN`. Не входят коммиты беты, а не задачи:
// кандидат беты или релиза (трейлер `Release:` и подпись кандидата или бандл,
// `bundle-policy.mjs`) и коммит бота `beta-derived` (#697). Коммиты задачи с
// `Release:` — приёмка эталонов, перепривязка тестов к бете — входят.
// Документация, документы ревью и бандл в объём не входят: перенос архива
// (#682) — не работа над кодом. Возвраты слияния и стража ребейза узнаются по
// признакам `merge-candidate.mjs`; раздел «Черновик ТЗ» — эпохи `S4` с
// черновиком (#729, §11.8) и S5 → S7 с черновиком и без.
//
// node scripts/process-metrics.mjs --repo=<owner/repo> --days=7 [--until=ISO] [--output=path.md] [--json=path.json]
// [--compare=2026-09-28] [--compare-days=28]
import { execFileSync } from 'node:child_process';
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { isMainModule } from './spawn-portable.mjs';
import { classify } from './change-classes.mjs';
import { labelTrack, parseNumstat } from './process-track.mjs';
import { issueTrailers } from './release-membership.mjs';
import { USAGE_KEYS, lastUsageIn } from './model-usage.mjs';
import { ANCHOR_MARKER, verdictDeclaration } from './review-doc-guard.mjs';
import { SHIP_REVIEW_ANCHOR, parseAnchorBlock } from './ship-review.mjs';
import { PIPELINE_EVENTS } from './wait-verdict.mjs';
import { OUTCOME_SIGNS, outcomeOf } from './merge-candidate.mjs';
import { committedBundleMustMatch, isReleaseMessage } from './bundle-policy.mjs';
export const STATUS_LABELS = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready', 'S6-in-progress', 'S7-code-review', 'S8-merged'];
const PROCESS_RUN = /^process #(\d+) · (S4-spec-review|S7-code-review)(?: ·|$)/;
const REVIEW_DOC = /^(CODE|SPEC)-REVIEW-(\d+)-r(\d+)\.md$/;
const at = (value) => {
const parsed = Date.parse(String(value || ''));
return Number.isFinite(parsed) ? parsed : NaN;
};
const minutes = (ms) => Math.round(ms / 60_000);
const hours = (ms) => Math.round((ms / 3_600_000) * 10) / 10;
const median = (values) => {
const sorted = values.filter(Number.isFinite).sort((a, b) => a - b);
if (!sorted.length) return null;
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
};
const medianHours = (values) => {
const value = median(values);
return value === null ? null : hours(value);
};
const mean = (values) => {
const finite = values.filter(Number.isFinite);
return finite.length ? finite.reduce((a, b) => a + b, 0) / finite.length : null;
};
/**
* Метрики одного issue по таймлайну меток.
* @param {object} issue { number, title, closed_at, state_reason, labels }
* @param {object[]} events timeline: { event: 'labeled'|'unlabeled', label: { name }, created_at }
*/
export function issueMetrics(issue, events = []) {
const labeled = (events || [])
.filter((event) => event?.event === 'labeled' && event.label?.name)
.map((event) => ({ label: event.label.name, at: at(event.created_at) }))
.filter((event) => Number.isFinite(event.at))
.sort((a, b) => a.at - b.at);
const first = (label) => labeled.find((event) => event.label === label)?.at ?? null;
const count = (label) => labeled.filter((event) => event.label === label).length;
const entered = labeled.find((event) => STATUS_LABELS.includes(event.label))?.at ?? null;
const s7 = first('S7-code-review');
const s8 = first('S8-merged');
const s4 = first('S4-spec-review');
const s5 = first('S5-ready');
const closed = at(issue.closed_at);
return {
number: Number(issue.number),
title: String(issue.title || ''),
stateReason: issue.state_reason || null,
enteredAt: entered,
s4At: s4, s5At: s5, s7At: s7, s8At: s8,
closedAt: Number.isFinite(closed) ? closed : null,
// Повторные постановки метки: S7 конвейер ставит сам после pending (#636),
// поэтому число S7-событий — не число раундов; раунды считаются по документам.
s7Requests: count('S7-code-review'),
s4Requests: count('S4-spec-review'),
leadToS7Ms: entered != null && s7 != null ? s7 - entered : null,
reviewToMergeMs: s7 != null && s8 != null ? s8 - s7 : null,
leadToS8Ms: entered != null && s8 != null ? s8 - entered : null,
specLeadMs: s4 != null && s5 != null ? s5 - s4 : null,
};
}
/**
* Имена документов ревью из `git ls-tree -r --name-only` по живому каталогу и
* архиву (#682): после стабильного релиза документы линии уезжают в
* `legacy/reviews/<тег>/`, и счёт раундов за окно не должен от этого меняться.
*/
export function reviewDocNames(listing = '') {
return String(listing).split('\n').map((path) => path.trim()).filter(Boolean)
.filter((path) => path.startsWith('docs/reviews/') || path.startsWith('legacy/reviews/'))
.map((path) => path.slice(path.lastIndexOf('/') + 1));
}
/** Раунды ревью по именам документов `docs/reviews/*-REVIEW-<NN>-r<K>.md`. */
export function reviewRounds(fileNames = []) {
const rounds = new Map();
for (const name of fileNames) {
const match = REVIEW_DOC.exec(String(name));
if (!match) continue;
const key = `${match[1]}:${match[2]}`;
const round = Number(match[3]);
rounds.set(key, Math.max(rounds.get(key) || 0, round));
}
return rounds;
}
/** Сводка по прогонам Actions за окно: по workflow — число, исходы, wall-time, события. */
export function runMetrics(runs = []) {
const byWorkflow = new Map();
for (const run of runs) {
const name = String(run.name || run.workflow_name || '');
// Прогоны конвейера именуются `process #NN · <метка> · …` на каждое событие метки;
// для сводки они — один workflow, а не сотни строк по меткам.
const key = /^process #\d+ · /.test(String(run.display_title || '')) ? 'process' : name;
const entry = byWorkflow.get(key) || {
workflow: key, runs: 0, success: 0, failure: 0, cancelled: 0, skipped: 0, other: 0, wallMs: 0, events: {},
};
entry.runs += 1;
const conclusion = run.conclusion || 'other';
if (conclusion in entry && typeof entry[conclusion] === 'number') entry[conclusion] += 1; else entry.other += 1;
const started = at(run.run_started_at || run.created_at);
const ended = at(run.updated_at);
if (Number.isFinite(started) && Number.isFinite(ended) && ended > started) entry.wallMs += ended - started;
entry.events[run.event] = (entry.events[run.event] || 0) + 1;
byWorkflow.set(key, entry);
}
return [...byWorkflow.values()].sort((a, b) => b.wallMs - a.wallMs);
}
/** Минуты конвейера по стадиям (имя прогона `process #NN · S7-code-review · …`). */
export function pipelineMetrics(runs = []) {
const stages = { 'S7-code-review': { runs: 0, wallMs: 0, issues: new Set() }, 'S4-spec-review': { runs: 0, wallMs: 0, issues: new Set() } };
for (const run of runs) {
const match = PROCESS_RUN.exec(String(run.display_title || run.name || ''));
if (!match || run.conclusion === 'skipped') continue;
const stage = stages[match[2]];
const started = at(run.run_started_at || run.created_at);
const ended = at(run.updated_at);
stage.runs += 1;
stage.issues.add(Number(match[1]));
if (Number.isFinite(started) && Number.isFinite(ended) && ended > started) stage.wallMs += ended - started;
}
return Object.fromEntries(Object.entries(stages).map(([label, stage]) => [label, {
runs: stage.runs, issues: stage.issues.size, wallMinutes: minutes(stage.wallMs),
perRunMinutes: stage.runs ? Math.round(minutes(stage.wallMs) / stage.runs) : null,
}]));
}
/** Job-минуты по jobs прогонов (если переданы): в целом и доля «Мутанты». */
export function jobMinutes(jobsByRun = new Map()) {
let total = 0;
let mutants = 0;
for (const jobs of jobsByRun.values()) {
for (const job of jobs || []) {
const started = at(job.started_at);
const ended = at(job.completed_at);
if (!Number.isFinite(started) || !Number.isFinite(ended) || ended <= started) continue;
total += ended - started;
if (/^Мутанты/.test(String(job.name || ''))) mutants += ended - started;
}
}
return { totalMinutes: minutes(total), mutantMinutes: minutes(mutants), mutantShare: total ? Math.round((mutants / total) * 100) : null };
}
// ---------------------------------------------------------------------------
// #728: эффект процесса по трекам.
const DAY_MS = 86_400_000;
/** Решение владельца о треках (PROCESS.md §5, #695): граница сравнения «до и после». */
export const TRACKS_CUTOVER = '2026-09-28';
export const COMPARE_DAYS = 28;
/** Минимальная когорта сравнения: меньше — «мало данных», разницы нет. */
export const MIN_COHORT = 3;
/** Jobs запрашиваются не больше чем для стольких прогонов окна. */
export const JOBS_RUN_CAP = 600;
/** Таймлайн issue читается не дальше стольких страниц по 100 событий. */
export const TIMELINE_PAGE_CAP = 10;
export const TRACKS = ['ship', 'show', 'ask'];
export const SEGMENTS = ['queue', 'spec', 'work', 'review', 'rework', 'blocked'];
export const RETURN_REASONS = ['verdict-yellow', 'verdict-red', 'validate-red', 'conflict', 'push-refused', 'merge', 'reclassify', 'owner-question', 'unknown'];
export const PIPELINE_STAGES = ['guard', 'prepare', 'model', 'integrate'];
export const VOLUME_BUCKETS = ['≤30', '31–200', '201–1000', '>1000'];
export const VALIDATE_WORKFLOW = 'Проверка (CI)';
export const TOKENS_NO_DATA = 'Токены: нет данных (ни один документ ревью не несёт расход модели)';
/**
* Причины `validate-red` и `conflict` (К3). У конвейера нет для них отдельной
* константы: оба комментария `_process.yml` («Validate красный — вернуть
* автору без ревью» и «Конфликт с dev — вернуть автору без ревью») идут под
* одним префиксом `PIPELINE_EVENTS` с `kind: 'conflict'`. Причину даёт
* продолжение первой строки. Это копия текста шаблонов, а не импорт: от
* расхождения её держит контрактный тест на самих шаблонах (AC3 #728).
*/
export const NOT_RUN_VALIDATE_RE = /^\*\*Ревью не запускалось:\*\* Validate(?: с мутантами)? на материале /m;
export const NOT_RUN_CONFLICT_RE = /^\*\*Ревью не запускалось:\*\* ветка \S+ не ребейзится на /m;
/** Маршрут вердикта show (#726): машинная строка комментария конвейера. */
export const ROUTE_RE = /<!--\s*hp:route\s+(reclassify|owner-question)\b[^>]*-->/;
/** Признак конвейера по `kind`: переименование в `wait-verdict.mjs` ломает загрузку, а не молча даёт `unknown`. */
function pipelineEvent(kind) {
const event = PIPELINE_EVENTS.find((entry) => entry.kind === kind);
if (!event) throw new Error(`process-metrics: в PIPELINE_EVENTS (wait-verdict.mjs) нет kind '${kind}'`);
return event.re;
}
/** Общий префикс «Ревью не запускалось»: семейство, не причина. */
const NOT_RUN_RE = pipelineEvent('conflict');
/** Неудачное слияние после ревью: «Слияние отменено», «Код-ревью зелёное — вердикт выше в силе». */
const MERGE_RES = [pipelineEvent('stale'), pipelineEvent('merge-conflict')];
/**
* #752: причина по исходу `merge-candidate.mjs` (`OUTCOME_SIGNS`). Отказ push
* стража ребейза до ревью (#705) — `push-refused`: код не читали, действует
* автор или владелец правом на workflow. Исход слияния после зелёного вердикта
* (кандидат красный или пропал, `dev` ушёл, отказ push, сбой шага) — `merge`:
* вердикт был, слить не удалось. `rereview` возвращает задачу в `S7`, а не
* автору, — причины не называет.
*/
if (!OUTCOME_SIGNS.some((sign) => sign.action === 'rereview')) {
throw new Error("process-metrics: в OUTCOME_SIGNS (merge-candidate.mjs) нет исхода 'rereview'");
}
const outcomeReason = (sign) => (sign.stage === 'rebase' ? 'push-refused' : sign.action === 'rereview' ? null : 'merge');
const toMs = (value) => (typeof value === 'number' ? value : at(value));
/** События меток таймлайна в порядке времени (при равном времени — в порядке таймлайна). */
function labelEvents(events = []) {
return (events || [])
.map((event, index) => ({ event, index }))
.filter(({ event }) => (event?.event === 'labeled' || event?.event === 'unlabeled') && event.label?.name)
.map(({ event, index }) => ({ type: event.event, label: event.label.name, at: at(event.created_at), index }))
.filter((event) => Number.isFinite(event.at))
.sort((a, b) => a.at - b.at || a.index - b.index);
}
/** Комментарии таймлайна (`commented`) в порядке времени. */
function commentEvents(events = []) {
return (events || [])
.map((event, index) => ({ event, index }))
.filter(({ event }) => event?.event === 'commented')
.map(({ event, index }) => ({ body: String(event.body ?? ''), at: at(event.created_at), index }))
.filter((comment) => Number.isFinite(comment.at))
.sort((a, b) => a.at - b.at || a.index - b.index);
}
/**
* Метки на задаче в момент `t`: все `labeled`/`unlabeled` строго до `t`
* (`inclusive` — включая события в сам момент `t`).
*/
export function labelsAt(events, t, { inclusive = false } = {}) {
const moment = toMs(t);
const labels = new Set();
for (const event of labelEvents(events)) {
if (inclusive ? event.at > moment : event.at >= moment) break;
if (event.type === 'labeled') labels.add(event.label); else labels.delete(event.label);
}
return [...labels];
}
/**
* К1. Трек на момент `t` — по меткам, стоящим на задаче до `t`. Трековые метки
* разрешает `process-track.mjs`, одним правилом с конвейером: строжайшая из
* нескольких `track:*`; прежние `small`/`trivial` — show; без метки — ask для
* продукта, show для инфраструктуры (`infra`, §5.1).
*/
export function trackAt(events, t, { infra = false } = {}) {
return labelTrack({ labels: labelsAt(events, t), infrastructure: infra });
}
/** Путь трека: разные треки подряд от `from` (включительно) до `to` (исключая), например `['ship', 'show']`. */
export function trackPath(events, { from, to, infra = false } = {}) {
const start = toMs(from);
const end = toMs(to);
const path = [];
const push = (track) => { if (path.at(-1) !== track) path.push(track); };
push(labelTrack({ labels: labelsAt(events, start, { inclusive: true }), infrastructure: infra }));
const moments = [...new Set(labelEvents(events).map((event) => event.at))].filter((moment) => moment > start && moment < end);
for (const moment of moments) push(labelTrack({ labels: labelsAt(events, moment, { inclusive: true }), infrastructure: infra }));
return path;
}
const SEGMENT_OF_STATUS = {
'S1-new': 'queue', 'S2-analysis': 'queue', 'S5-ready': 'queue',
'S4-spec-review': 'review', 'S7-code-review': 'review',
};
/**
* К2. Отрезки от первой статусной метки до первого `S8-merged`. Статус в
* каждый момент — последняя поставленная статусная метка, `blocked` —
* наложение поверх статуса: его время вычитается из отрезка под ним. Возврат —
* смена статуса `S7 → S6` (и `S7 → S3`, маршрут reclassify #726) или `S4 → S3`;
* постановка `S7` поверх `S7` — не возврат и не новый отрезок. Сумма отрезков
* равна `lead`. Без `S8` — `null`.
*/
export function issueSegments(events = []) {
const list = labelEvents(events);
const s8 = list.find((event) => event.type === 'labeled' && event.label === 'S8-merged');
if (!s8) return null;
const segments = Object.fromEntries(SEGMENTS.map((name) => [name, 0]));
const returns = [];
let status = null;
let enteredAt = null;
let reviewSince = null;
let blocked = false;
let specReturned = false;
let codeReturned = false;
let cursor = null;
const current = () => {
if (blocked) return 'blocked';
if (status === 'S3-spec') return specReturned || codeReturned ? 'rework' : 'spec';
if (status === 'S6-in-progress') return codeReturned ? 'rework' : 'work';
return SEGMENT_OF_STATUS[status] || 'queue';
};
for (const event of list) {
if (cursor !== null && event.at > cursor) {
segments[current()] += event.at - cursor;
cursor = event.at;
}
if (event === s8) break;
if (event.label === 'blocked') { blocked = event.type === 'labeled'; continue; }
if (event.type !== 'labeled' || !STATUS_LABELS.includes(event.label) || event.label === status) continue;
if (status === 'S7-code-review' && (event.label === 'S6-in-progress' || event.label === 'S3-spec')) {
codeReturned = true;
returns.push({ stage: 'code', from: status, to: event.label, since: reviewSince, at: event.at });
} else if (status === 'S4-spec-review' && event.label === 'S3-spec') {
specReturned = true;
returns.push({ stage: 'spec', from: status, to: event.label, since: reviewSince, at: event.at });
}
status = event.label;
if (status === 'S4-spec-review' || status === 'S7-code-review') reviewSince = event.at;
if (cursor === null) { cursor = event.at; enteredAt = event.at; }
}
if (enteredAt === null) enteredAt = s8.at;
return { enteredAt, s8At: s8.at, leadMs: s8.at - enteredAt, segments, returns };
}
/** Цвет объявленного вердикта по строке `verdictDeclaration`: первый цвет после слова «Вердикт». */
function verdictColour(line) {
const match = /(красн)|(жёлт|желт)|(зелён|зелен)/i.exec(String(line).slice(Math.max(0, String(line).indexOf('Вердикт'))));
if (!match) return null;
return match[1] ? 'red' : match[2] ? 'yellow' : 'green';
}
/**
* Вердикт этапа в комментарии: объявление (`verdictDeclaration`) и документ
* этой задачи и этого этапа `<MARKER>-<NN>` — то же правило, что у счёта
* циклов конвейера (`stageVerdictComments`). Вердикт о чужом документе — `null`.
*/
export function stageVerdict(body, { stage = 'code', number } = {}) {
if (!/^\d+$/.test(String(number ?? ''))) return null;
const marker = stage === 'spec' ? 'SPEC-REVIEW' : 'CODE-REVIEW';
const text = String(body ?? '');
if (!new RegExp(`${marker}-${number}(?![0-9])`).test(text)) return null;
const line = verdictDeclaration(text);
return line ? verdictColour(line) : null;
}
/**
* К3. Признак причины возврата в одном комментарии; `null` — комментарий
* причины не называет. «Ревью не запускалось» с другим продолжением —
* `unknown`: семейство узнано, причина не угадывается. Исходы слияния и
* стража ребейза — по признакам `merge-candidate.mjs` (#752).
*/
export function returnSignal(body, { stage = 'code', number } = {}) {
const text = String(body ?? '');
const route = ROUTE_RE.exec(text);
if (route) return route[1];
const outcome = outcomeOf(text);
if (outcome) return outcomeReason(outcome);
if (NOT_RUN_RE.test(text)) {
if (NOT_RUN_VALIDATE_RE.test(text)) return 'validate-red';
if (NOT_RUN_CONFLICT_RE.test(text)) return 'conflict';
return 'unknown';
}
if (MERGE_RES.some((re) => re.test(text))) return 'merge';
const colour = stageVerdict(text, { stage, number });
if (colour === 'red') return 'verdict-red';
if (colour === 'yellow') return 'verdict-yellow';
return null;
}
/**
* Причина возврата — последний комментарий с признаком К3 между постановкой
* ревью (`since`) и возвратом (`at`), обе границы включительно. Комментарии
* без признака (разговор, «Пока шло ревью…», «Лимит циклов…») причину не
* перекрывают. Ничего — `unknown`: метку переставил человек.
*/
export function returnReason(comments = [], { since, at: returnedAt, stage = 'code', number } = {}) {
const from = toMs(since);
const to = toMs(returnedAt);
const window = (comments || []).filter((comment) => {
const moment = toMs(comment.at ?? comment.created_at);
return Number.isFinite(moment) && moment >= from && moment <= to;
});
for (let i = window.length - 1; i >= 0; i--) {
const signal = returnSignal(window[i].body, { stage, number });
if (signal) return signal;
}
return 'unknown';
}
/** Раунды ревью по комментариям: объявление вердикта этапа своей задачи до `until`. */
export function verdictRounds(comments = [], { number, until = Infinity } = {}) {
const rounds = [];
for (const comment of comments || []) {
const moment = toMs(comment.at ?? comment.created_at);
if (!Number.isFinite(moment) || moment > toMs(until)) continue;
for (const stage of ['code', 'spec']) {
const colour = stageVerdict(comment.body, { stage, number });
if (!colour) continue;
rounds.push({ stage, colour, blocking: colour !== 'green', at: moment });
break;
}
}
return rounds;
}
const SHIP_DOC = /^(?:docs\/reviews|legacy\/reviews)\/(?:.*\/)?SHIP-REVIEW-[^/]+\.md$/;
/**
* К4. Находки пакетного ревью ship по документам `SHIP-REVIEW-*.md` живого
* каталога и архива: счёт High/Medium/Low — по документу (по задаче
* серьёзность не записывается), у задачи — сумма по документам, чей
* машинный блок её называет.
*/
export function shipFindings(reviewDocs = []) {
const byIssue = new Map();
const docs = [];
for (const doc of reviewDocs || []) {
if (!SHIP_DOC.test(String(doc?.path || ''))) continue;
const block = parseAnchorBlock(doc.text);
if (!block) continue;
const entry = { path: doc.path, issues: block.issues, high: block.high ?? 0, medium: block.medium ?? 0, low: block.low ?? 0 };
docs.push(entry);
for (const number of block.issues) {
const sum = byIssue.get(number) || { high: 0, medium: 0, low: 0, docs: [] };
sum.high += entry.high; sum.medium += entry.medium; sum.low += entry.low; sum.docs.push(doc.path);
byIssue.set(number, sum);
}
}
return { byIssue, docs };
}
/**
* #737: строка расхода модели документа ревью — только из машинного блока,
* который пишет конвейер: после `ANCHOR_MARKER` (ревью ТЗ и кода) или после
* последнего `SHIP_REVIEW_ANCHOR` (`SHIP-REVIEW-*`). Проза выше маркера не
* источник: ревьюер r2 цитирует документ r1, и расход r1 считался бы дважды.
* Формат и разбор — `model-usage.mjs`; строки нет — `null`.
*/
export function reviewDocUsage(doc) {
const text = String(doc?.text ?? '');
const at = SHIP_DOC.test(String(doc?.path || '')) ? text.lastIndexOf(SHIP_REVIEW_ANCHOR) : text.indexOf(ANCHOR_MARKER);
return at < 0 ? null : lastUsageIn(text.slice(at));
}
/**
* К6 (#728, #737). Расход модели по документам ревью: `docs` — документы с
* данными, `totals` — суммы по ключам строки (`null`, пока данных нет),
* `missing` — документы с `hp:usage-none`. «Нет данных» — не ноль: в суммы
* не входит. Документы без строки (до #737) не считаются ни тем, ни другим.
* Окно недели (#761) применяет вызывающий: `tokenDocs`.
*/
export function tokenUsage(reviewDocs = []) {
const totals = Object.fromEntries(USAGE_KEYS.map((key) => [key, 0]));
let docs = 0;
let missing = 0;
for (const doc of reviewDocs || []) {
const usage = reviewDocUsage(doc);
if (!usage) continue;
if ('reason' in usage) { missing += 1; continue; }
docs += 1;
for (const key of USAGE_KEYS) totals[key] += usage[key];
}
return { docs, totals: docs ? totals : null, missing };
}
/**
* #761: дата добавления документа ревью в `dev` по выводу
* `git log -M --diff-filter=AR --reverse --name-status --format=%x1e%cI`.
* Добавление (`A`) ставит дату коммиттера; переименование (`R`, перенос в
* архив `legacy/reviews/` #682) переносит дату на новый путь, а не добавляет
* документ заново. → Map путь в `HEAD` → дата (ISO).
*/
export function reviewDocAddedAt(text = '') {
const added = new Map();
for (const record of String(text).split('\x1e')) {
const [date = '', ...rows] = record.split('\n');
for (const row of rows) {
const [status = '', from, to] = row.split('\t');
if (status === 'A' && from) added.set(from, date.trim());
else if (/^R\d*$/.test(status) && to && added.has(from)) {
added.set(to, added.get(from));
added.delete(from);
}
}
}
return added;
}
/**
* #761: документы для токенов недели — добавленные в `dev` в `[since, until]`
* (`reviewDocAddedAt`). Документ, чьё добавление не найдено, в окно не входит.
* Без карты дат (`null`: снимок без git, юнит над готовыми документами) окна
* нет, как до #761.
*/
export function tokenDocs(reviewDocs = [], { added = null, since, until } = {}) {
if (!added) return reviewDocs || [];
const from = toMs(since);
const to = toMs(until);
return (reviewDocs || []).filter((doc) => {
const moment = at(added.get(String(doc?.path ?? '')));
return moment >= from && moment <= to;
});
}
/** `git log --format=%x1e%H%x1f%cI%x1f%B%x1f --numstat` → коммиты с трейлерами и строками. */
export function parseGitLog(text = '') {
return String(text).split('\x1e').filter((record) => record.trim()).map((record) => {
const [sha, date, body = '', numstat = ''] = record.split('\x1f');
return { sha: sha.trim(), date: date?.trim() || null, body, issues: issueTrailers(body), files: parseNumstat(numstat) };
}).filter((commit) => /^[0-9a-f]{7,64}$/.test(commit.sha));
}
/** Коммиты `ref` с `since` (дата коммиттера) — с `--numstat`, без переименований. */
export function readCommits(git, { ref = 'origin/dev', since } = {}) {
const args = ['log', ref, '--no-renames', '--numstat', '--format=%x1e%H%x1f%cI%x1f%B%x1f'];
if (since) args.splice(2, 0, `--since=${new Date(toMs(since)).toISOString()}`);
return parseGitLog(git(args));
}
/** Строка объёма К7 (#752): классы A и B — продукт, тесты, инструменты. */
const countsToVolume = (path) => ['A', 'B'].includes(classify(path));
/** Файл задачи для признака инфраструктуры (#752): A, B, C, кроме документов ревью — их пишет конвейер. */
const isTaskFile = (path) => ['A', 'B', 'C'].includes(classify(path)) && !String(path).startsWith('docs/reviews/');
/**
* Подпись коммита бота `beta-derived` (#697). Константы у конвейера нет —
* сообщение собирает `_beta-derived.yml`; копию держит контрактный тест.
*/
export const BETA_DERIVED_SUBJECT = /^docs: accept derived artifacts on dev for v\d/;
/**
* Коммит беты, а не задачи (#752): он несёт трейлеры всех задач линии.
* Кандидат беты или релиза — трейлер `Release:` и подпись кандидата либо
* изменённый бандл (`committedBundleMustMatch`: с #657 бандл меняет только
* кандидат); коммит бота `beta-derived` — по подписи. Прочий коммит с
* `Release:` (приёмка эталонов, перепривязка тестов к бете) — коммит задачи.
*/
export function isBetaCommit(commit) {
const body = String(commit?.body ?? '');
const subject = body.split('\n', 1)[0];
if (BETA_DERIVED_SUBJECT.test(subject)) return true;
return isReleaseMessage(body) && committedBundleMustMatch({ subject, files: (commit?.files || []).map((file) => file.path) });
}
/**
* Изменения задачи по коммитам с трейлером `Issue: #NN`, кроме коммитов беты
* (`isBetaCommit`): объём К7 (`+/−` классов A и B) и файлы задачи для признака
* инфраструктуры К1 (A, B, C без `docs/reviews/**`).
*/
export function issueChanges(commits = []) {
const byIssue = new Map();
for (const commit of commits || []) {
if (isBetaCommit(commit)) continue;
for (const number of commit.issues || issueTrailers(commit.body)) {
const entry = byIssue.get(number) || { lines: 0, files: new Set(), commits: 0 };
entry.commits += 1;
for (const file of commit.files || []) {
if (isTaskFile(file.path)) entry.files.add(file.path);
if (countsToVolume(file.path)) entry.lines += (file.added ?? 0) + (file.deleted ?? 0);
}
byIssue.set(number, entry);
}
}
return byIssue;
}
/**
* Инфраструктура (§1): у задачи есть файлы задачи и ни одного класса A.
* Задача только с документами ревью — признак не доказан.
*/
export const isInfra = (change) => Boolean(change && change.files.size && [...change.files].every((path) => classify(path) !== 'A'));
/** Объём задачи К7: строки A+B; без файлов задачи (только документы ревью, бандл) — `null`, объёма нет. */
export const changeVolume = (change) => (change && change.files.size ? change.lines : null);
/** Корзина объёма К7: ≤ 30, 31–200, 201–1000, > 1000 строк. */
export function volumeBucket(lines) {
if (!Number.isFinite(lines)) return null;
if (lines <= 30) return VOLUME_BUCKETS[0];
if (lines <= 200) return VOLUME_BUCKETS[1];
if (lines <= 1000) return VOLUME_BUCKETS[2];
return VOLUME_BUCKETS[3];
}
/**
* Задача для разделов по трекам: трек на момент первого `S8-merged`, путь
* трека, отрезки, возвраты и раунды с треком своего момента, находки ship.
* Без `S8` — `null`. Усечённый таймлайн — строка без отрезков (`truncated`).
*/
export function issueTrackMetrics(issue, events = [], { change = null, ship = null, truncated = false } = {}) {
const number = Number(issue.number);
const s8 = labelEvents(events).find((event) => event.type === 'labeled' && event.label === 'S8-merged');
if (!s8) return null;
const infra = isInfra(change);
const volume = changeVolume(change);
const base = {
number, title: String(issue.title || ''), stateReason: issue.state_reason || null, s8At: s8.at,
infra, volume, bucket: volumeBucket(volume),
};
if (truncated) return { ...base, truncated: true, track: null, path: [], leadMs: null, segments: null, returns: [], rounds: [], ship: null };
const seg = issueSegments(events);
const comments = commentEvents(events);
const track = trackAt(events, s8.at, { infra });
return {
...base,
truncated: false,
track,
path: trackPath(events, { from: seg.enteredAt, to: s8.at, infra }),
enteredAt: seg.enteredAt,
leadMs: seg.leadMs,
segments: seg.segments,
returns: seg.returns.map((ret) => ({
stage: ret.stage, from: ret.from, to: ret.to, at: ret.at,
reason: returnReason(comments, { since: ret.since, at: ret.at, stage: ret.stage, number }),
track: trackAt(events, ret.at, { infra }),
})),
rounds: verdictRounds(comments, { number, until: s8.at }).map((round) => ({ ...round, track: trackAt(events, round.at, { infra }) })),
ship: track === 'ship' ? (ship || { high: 0, medium: 0, low: 0, docs: [] }) : null,
};
}
const trackOrder = (track) => (TRACKS.includes(track) ? TRACKS.indexOf(track) : TRACKS.length);
/**
* Разделы «По трекам»: задачи по треку на момент S8; возвраты и раунды — по
* треку своего момента. Находки ship — сумма по разным документам, которые
* называют ship-задачи раздела: документ на две задачи считается один раз.
*/
export function trackSection(rows = [], { shipDocs = [] } = {}) {
const counted = rows.filter((row) => !row.truncated);
const tracks = [...new Set([...TRACKS, ...counted.map((row) => row.track)])].sort((a, b) => trackOrder(a) - trackOrder(b));
const byTrack = tracks.map((track) => {
const own = counted.filter((row) => row.track === track);
const medians = Object.fromEntries(['lead', ...SEGMENTS].map((name) => [name, medianHours(own.map((row) => (name === 'lead' ? row.leadMs : row.segments[name])))]));
let ship = null;
if (track === 'ship') {
const numbers = new Set(own.map((row) => row.number));
const docs = (shipDocs || []).filter((doc) => doc.issues.some((number) => numbers.has(number)));
ship = {
issues: own.length,
covered: own.filter((row) => row.ship?.docs.length).length,
docs: docs.length,
high: docs.reduce((sum, doc) => sum + doc.high, 0),
medium: docs.reduce((sum, doc) => sum + doc.medium, 0),
low: docs.reduce((sum, doc) => sum + doc.low, 0),
};
}
return { track, n: own.length, medians, ship };
});
const events = tracks.map((track) => {
const returns = counted.flatMap((row) => row.returns).filter((ret) => ret.track === track);
const rounds = counted.flatMap((row) => row.rounds).filter((round) => round.track === track);
return {
track,
returns: returns.length,
reasons: Object.fromEntries(RETURN_REASONS.map((reason) => [reason, returns.filter((ret) => ret.reason === reason).length])),
rounds: Object.fromEntries(['code', 'spec'].map((stage) => [stage, {
blocking: rounds.filter((round) => round.stage === stage && round.blocking).length,
green: rounds.filter((round) => round.stage === stage && !round.blocking).length,
}])),
};
});
return {
n: counted.length,
byTrack, events,
truncated: rows.filter((row) => row.truncated).map((row) => row.number),
changed: counted.filter((row) => row.path.length > 1).map((row) => ({ number: row.number, path: row.path })),
rows: [...rows].sort((a, b) => a.number - b.number),
};
}
// ---------------------------------------------------------------------------
// #752 П.4: черновик ТЗ (#729, PROCESS.md §11.8).
/**
* Комментарий «Черновик:» по шаблону §7.2 — пишет автор, не конвейер; копию
* держит контрактный тест на PROCESS.md.
*/
export const DRAFT_COMMENT_RE = /^\s*Черновик:/;
/** Трейлер чернового коммита (§11.8) — как читает его `process-gate.mjs`. */
const SPEC_DRAFT_TRAILER = /^Spec-Draft:\s*\S/mi;
/** Трек на момент `t`, включая метки, поставленные в сам момент `t` (как начало пути трека). */
const trackDuring = (events, t, infra) => labelTrack({ labels: labelsAt(events, t, { inclusive: true }), infrastructure: infra });
/**
* Эпохи `S4-spec-review`: от постановки `S4` до следующей статусной метки с
* другим именем — статус как в `issueSegments`, повторная постановка `S4`
* эпоху не начинает. `close` — метка, закрывшая эпоху; открытая — `null`.
*/
export function specEpochs(events = []) {
const epochs = [];
let status = null;
let open = null;
for (const event of labelEvents(events)) {
if (event.type !== 'labeled' || !STATUS_LABELS.includes(event.label) || event.label === status) continue;
if (open) epochs.push({ ...open, end: event.at, close: event.label });
status = event.label;
open = status === 'S4-spec-review' ? { start: event.at } : null;
}
if (open) epochs.push({ ...open, end: null, close: null });
return epochs;
}
/**
* Раздел «Черновик ТЗ» по задачам с первым `S5-ready` в окне:
* - эпохи `S4` на треке ask (трек — на момент начала эпохи), из них с
* комментарием «Черновик:» внутри эпохи и их исход: `S5-ready` — черновик
* пошёл в дело, `S3-spec` — выброшен, прочее — открыта или закрыта иначе;
* - S5 → S7 — от первого `S5-ready` до первого `S7-code-review` после него
* (не позже `until`), по треку на момент S5; «с черновиком» — эпоху `S4`,
* закрытую этим `S5-ready`, вёл черновик. n < `MIN_COHORT` — мало данных;
* - коммиты `dev` с трейлером `Spec-Draft:` и датой коммиттера в окне.
*/
export function draftSection({ issues = [], timelines = new Map(), changes = new Map(), commits = [], since, until } = {}) {
const from = toMs(since);
const to = toMs(until);
const inWindow = (moment) => moment >= from && moment <= to;
const epochs = { total: 0, withDraft: 0, used: 0, thrown: 0, other: 0 };
const spans = [];
const noS7 = [];
const tasks = [];
const seen = new Set();
for (const issue of issues || []) {
const number = Number(issue.number);
if (seen.has(number)) continue;
seen.add(number);
const events = timelines.get(number) || [];
const labels = labelEvents(events).filter((event) => event.type === 'labeled');
const s5 = labels.find((event) => event.label === 'S5-ready');
if (!s5 || !inWindow(s5.at)) continue;
tasks.push(number);
const infra = isInfra(changes.get(number));
const drafts = commentEvents(events).filter((comment) => DRAFT_COMMENT_RE.test(comment.body));
let draftedS5 = false;
for (const epoch of specEpochs(events)) {
const drafted = drafts.some((comment) => comment.at >= epoch.start && (epoch.end === null || comment.at <= epoch.end));
if (epoch.close === 'S5-ready' && epoch.end === s5.at) draftedS5 = drafted;
if (trackDuring(events, epoch.start, infra) !== 'ask') continue;
epochs.total += 1;
if (!drafted) continue;
epochs.withDraft += 1;
if (epoch.close === 'S5-ready') epochs.used += 1;
else if (epoch.close === 'S3-spec') epochs.thrown += 1;
else epochs.other += 1;
}
const s7 = labels.find((event) => event.label === 'S7-code-review' && event.at >= s5.at);
if (!s7 || s7.at > to) { noS7.push(number); continue; }
spans.push({ number, track: trackDuring(events, s5.at, infra), drafted: draftedS5, ms: s7.at - s5.at });
}
const tracks = [...new Set([...TRACKS, ...spans.map((span) => span.track)])].sort((a, b) => trackOrder(a) - trackOrder(b));
const group = (list) => ({ n: list.length, hours: medianHours(list.map((span) => span.ms)), enough: list.length >= MIN_COHORT });
const drafted = (commits || []).filter((commit) => SPEC_DRAFT_TRAILER.test(String(commit.body || '')) && inWindow(at(commit.date)));
return {
tasks: tasks.length,
epochs,
s5ToS7: tracks.map((track) => {
const own = spans.filter((span) => span.track === track);
return { track, draft: group(own.filter((span) => span.drafted)), plain: group(own.filter((span) => !span.drafted)) };
}),
noS7: noS7.sort((a, b) => a - b),
specDraftCommits: { count: drafted.length, issues: [...new Set(drafted.flatMap((commit) => commit.issues || []))].sort((a, b) => a - b) },
};
}
/** Стадия конвейера по имени job тела — части после « / »: «Страж…», «Ревью: материал…», … */
export function jobStage(name = '') {
const text = String(name);
const own = text.includes(' / ') ? text.slice(text.indexOf(' / ') + 3) : text;
if (/^Страж/.test(own)) return 'guard';
if (/^Ревью: материал/.test(own)) return 'prepare';
if (/^Ревью: работа модели/.test(own)) return 'model';
if (/^Ревью: публикация/.test(own)) return 'integrate';
return 'other';
}
const PROCESS_TITLE = /^process #(\d+) · /;
/**
* Прогоны, для которых запрашиваются jobs (К5): конвейер `process #NN · …` и
* Validate, кроме skipped (у них нет ни одной выполненной job), не больше `cap`.
*/
export function jobRuns(runs = [], cap = JOBS_RUN_CAP) {
const eligible = (runs || []).filter((run) => run?.conclusion !== 'skipped'
&& (PROCESS_TITLE.test(String(run.display_title || '')) || String(run.name || '') === VALIDATE_WORKFLOW));
return { total: eligible.length, selected: eligible.slice(0, cap) };
}
const jobMs = (job) => {
const started = at(job?.started_at);
const ended = at(job?.completed_at);
return Number.isFinite(started) && Number.isFinite(ended) && ended > started ? ended - started : 0;
};
/**
* К5. Job-минуты конвейера по стадиям — всего и по треку задачи на момент
* прогона (К1); Validate — всего и по событию. Jobs прогона недоступны
* (`null`: 403, истёк срок хранения) — прогон не даёт данных; ни одного
* прогона с данными — `null` («нет данных»), а не ноль.
*/
export function stageMinutes({ runs = [], jobsByRun = new Map(), trackOf = () => null, cap = JOBS_RUN_CAP } = {}) {
const { total, selected } = jobRuns(runs, cap);
const stagesMs = () => Object.fromEntries([...PIPELINE_STAGES, 'other'].map((stage) => [stage, 0]));
const processAll = stagesMs();
const processByTrack = {};
const validateByEvent = {};
let validateMs = 0;
let processRuns = 0;
let validateRuns = 0;
let unavailable = 0;
for (const run of selected) {
const jobs = jobsByRun.get(run.id) ?? jobsByRun.get(String(run.id)) ?? null;
if (!Array.isArray(jobs)) { unavailable += 1; continue; }
const process = PROCESS_TITLE.exec(String(run.display_title || ''));
if (process) {
processRuns += 1;
const track = trackOf(Number(process[1]), at(run.run_started_at || run.created_at)) || '—';
processByTrack[track] ||= stagesMs();
for (const job of jobs) {
const stage = jobStage(job.name);
processAll[stage] += jobMs(job);
processByTrack[track][stage] += jobMs(job);
}
} else {
validateRuns += 1;
const event = String(run.event || 'other');
for (const job of jobs) {
validateMs += jobMs(job);
validateByEvent[event] = (validateByEvent[event] || 0) + jobMs(job);
}
}
}
const toMinutes = (stages) => Object.fromEntries(Object.entries(stages).map(([stage, ms]) => [stage, minutes(ms)]));
return {
runs: total,
fetched: selected.length,
truncated: total > selected.length ? { fetched: selected.length, total } : null,
unavailable,
process: processRuns ? {
runs: processRuns,
stages: toMinutes(processAll),
byTrack: Object.fromEntries(Object.entries(processByTrack)
.sort(([a], [b]) => trackOrder(a) - trackOrder(b)).map(([track, stages]) => [track, toMinutes(stages)])),
} : null,
validate: validateRuns ? {
runs: validateRuns,
minutes: minutes(validateMs),
byEvent: Object.fromEntries(Object.entries(validateByEvent).map(([event, ms]) => [event, minutes(ms)])),
} : null,
};
}
/**
* К7. Сравнение сопоставимых задач до и после `cutover`: первый `S8-merged`
* в `[cutover − days, cutover)` и `[cutover, min(cutover + days, until))`,
* когорта — трек на момент S8 и корзина объёма. n < `MIN_COHORT` на любой
* стороне — «мало данных», разницы нет. Задачи без коммитов с трейлером или
* только с документами ревью объёма не имеют и в когорты не входят.
*/
export function compareCohorts(rows = [], { cutover = TRACKS_CUTOVER, days = COMPARE_DAYS, until = null } = {}) {
const border = toMs(cutover);
const beforeFrom = border - days * DAY_MS;
const afterTo = Math.min(border + days * DAY_MS, until == null ? Infinity : toMs(until));
const sideOf = (row) => {
if (row.s8At >= beforeFrom && row.s8At < border) return 'before';
if (row.s8At >= border && row.s8At < afterTo) return 'after';
return null;
};
const inWindow = rows.filter((row) => !row.truncated && sideOf(row));
const noCommits = inWindow.filter((row) => row.bucket == null).map((row) => row.number).sort((a, b) => a - b);
const cohorts = new Map();
for (const row of inWindow.filter((r) => r.bucket != null)) {
const key = `${row.track} · ${row.bucket}`;
const cohort = cohorts.get(key) || { key, track: row.track, bucket: row.bucket, before: [], after: [] };
cohort[sideOf(row)].push(row);
cohorts.set(key, cohort);
}
const stats = (side) => ({
n: side.length,
lead: medianHours(side.map((row) => row.leadMs)),
work: medianHours(side.map((row) => row.segments.work)),
review: medianHours(side.map((row) => row.segments.review)),
rework: medianHours(side.map((row) => row.segments.rework)),
returns: side.length ? Math.round(mean(side.map((row) => row.returns.length)) * 100) / 100 : null,
});
const delta = (a, b) => (a == null || b == null ? null : Math.round((b - a) * 100) / 100);
const list = [...cohorts.values()]
.sort((a, b) => trackOrder(a.track) - trackOrder(b.track) || VOLUME_BUCKETS.indexOf(a.bucket) - VOLUME_BUCKETS.indexOf(b.bucket))
.map((cohort) => {
const before = stats(cohort.before);
const after = stats(cohort.after);
const enough = before.n >= MIN_COHORT && after.n >= MIN_COHORT;
return {
key: cohort.key, track: cohort.track, bucket: cohort.bucket, before, after, enough,
diff: enough ? Object.fromEntries(['lead', 'work', 'review', 'rework', 'returns'].map((name) => [name, delta(before[name], after[name])])) : null,
};
});
return {
cutover: new Date(border).toISOString().slice(0, 10),
days,
before: { from: new Date(beforeFrom).toISOString(), to: new Date(border).toISOString() },
after: { from: new Date(border).toISOString(), to: Number.isFinite(afterTo) ? new Date(afterTo).toISOString() : null },
cohorts: list,
noCommits,
};
}
/** Собрать всё в один отчёт. */
export function buildReport({
since, until, issues = [], timelines = new Map(), reviewFiles = [], runs = [], jobsByRun = null,
// #728: задачи для разделов по трекам (по умолчанию — те же `issues`), усечённые
// таймлайны, тексты документов ревью, коммиты dev и окно сравнения.
allIssues = null, timelineTruncated = new Set(), reviewDocs = [], commits = [], compare = {},
// #761: дата добавления документов ревью в dev — окно токенов; `null` — окна нет.
reviewDocAdded = null,
}) {
const perIssue = issues.map((issue) => issueMetrics(issue, timelines.get(Number(issue.number)) || []));
const rounds = reviewRounds(reviewFiles);
const completed = perIssue.filter((issue) => issue.stateReason === 'completed');
const codeRounds = completed.map((issue) => rounds.get(`CODE:${issue.number}`)).filter(Boolean);
const specRounds = completed.map((issue) => rounds.get(`SPEC:${issue.number}`)).filter(Boolean);
const dist = (values) => values.reduce((acc, value) => (acc[value] = (acc[value] || 0) + 1, acc), {});
return {
since, until,
issues: {
closed: perIssue.length,
completed: completed.length,
notPlanned: perIssue.filter((issue) => issue.stateReason === 'not_planned').length,
medianLeadToS7Hours: medianHours(completed.map((i) => i.leadToS7Ms)),
medianReviewToMergeHours: medianHours(completed.map((i) => i.reviewToMergeMs)),
medianLeadToS8Hours: medianHours(completed.map((i) => i.leadToS8Ms)),
medianSpecLeadHours: medianHours(completed.map((i) => i.specLeadMs)),
codeReviewRounds: { mean: mean(codeRounds), distribution: dist(codeRounds), issues: codeRounds.length },
specReviewRounds: { mean: mean(specRounds), distribution: dist(specRounds), issues: specRounds.length },
s7RepeatRequests: completed.filter((i) => i.s7Requests > 1).length,
rows: perIssue.map((issue) => ({
...issue,
codeRounds: rounds.get(`CODE:${issue.number}`) || 0,
specRounds: rounds.get(`SPEC:${issue.number}`) || 0,
})),
},
runs: runMetrics(runs),
pipeline: pipelineMetrics(runs),
jobs: jobsByRun ? jobMinutes(jobsByRun) : null,
...trackReport({ since, until, issues: allIssues || issues, timelines, timelineTruncated, reviewDocs, reviewDocAdded, runs, jobsByRun, commits, compare }),
};
}
/** #728: разделы по трекам, job-минуты по стадиям, токены и сравнение до/после. */
function trackReport({ since, until, issues, timelines, timelineTruncated, reviewDocs, reviewDocAdded, runs, jobsByRun, commits, compare }) {
const changes = issueChanges(commits);
const ship = shipFindings(reviewDocs);
const seen = new Set();
const rows = [];
for (const issue of issues || []) {
const number = Number(issue.number);
if (seen.has(number)) continue;
seen.add(number);
const row = issueTrackMetrics(issue, timelines.get(number) || [], {
change: changes.get(number) || null, ship: ship.byIssue.get(number) || null, truncated: timelineTruncated.has(number),
});
if (row) rows.push(row);
}
const from = at(since);
const to = at(until);
const trackOf = (number, moment) => {
const events = timelines.get(number);
return events && Number.isFinite(moment) ? trackAt(events, moment, { infra: isInfra(changes.get(number)) }) : null;
};
return {
tracks: trackSection(rows.filter((row) => row.s8At >= from && row.s8At <= to), { shipDocs: ship.docs }),
drafts: draftSection({ issues, timelines, changes, commits, since, until }),
stages: jobsByRun ? stageMinutes({ runs, jobsByRun, trackOf }) : null,
// #761: токены и `missing` — по документам, добавленным в dev за окно; `window` — окно применено.
tokens: reviewDocAdded
? { ...tokenUsage(tokenDocs(reviewDocs, { added: reviewDocAdded, since, until })), window: { since, until } }
: tokenUsage(reviewDocs),
compare: compareCohorts(rows, { cutover: compare.cutover ?? TRACKS_CUTOVER, days: compare.days ?? COMPARE_DAYS, until }),
};
}
const fmt = (value, suffix = '') => (value == null || Number.isNaN(value) ? '—' : `${typeof value === 'number' && !Number.isInteger(value) ? value.toFixed(2) : value}${suffix}`);
export function renderMarkdown(report) {
const lines = [];
lines.push(`## Метрики процесса · ${String(report.since).slice(0, 10)} — ${String(report.until).slice(0, 10)}`);
lines.push('');
const i = report.issues;
lines.push(`Закрыто issue: **${i.closed}** (completed ${i.completed}, not_planned ${i.notPlanned}).`);
lines.push('');
lines.push('| Метрика | Значение |');
lines.push('|---|---|');
lines.push(`| Медиана вход → S7 | ${fmt(i.medianLeadToS7Hours, ' ч')} |`);
lines.push(`| Медиана S7 → S8 | ${fmt(i.medianReviewToMergeHours, ' ч')} |`);
lines.push(`| Медиана вход → S8 | ${fmt(i.medianLeadToS8Hours, ' ч')} |`);
lines.push(`| Медиана S4 → S5 (ревью ТЗ) | ${fmt(i.medianSpecLeadHours, ' ч')} |`);
lines.push(`| Раундов код-ревью на issue | ${fmt(i.codeReviewRounds.mean)} (${Object.entries(i.codeReviewRounds.distribution).map(([r, n]) => `r${r}: ${n}`).join(', ') || '—'}) |`);
lines.push(`| Раундов ревью ТЗ на issue | ${fmt(i.specReviewRounds.mean)} (${Object.entries(i.specReviewRounds.distribution).map(([r, n]) => `r${r}: ${n}`).join(', ') || '—'}) |`);
lines.push(`| Issue с повторной S7 | ${i.s7RepeatRequests} |`);
lines.push('');
lines.push('### Прогоны Actions');
lines.push('');
lines.push('| Workflow | Прогонов | ok | red | cancel | skipped | Wall, мин |');
lines.push('|---|---:|---:|---:|---:|---:|---:|');
for (const run of report.runs) {
lines.push(`| ${run.workflow} | ${run.runs} | ${run.success} | ${run.failure} | ${run.cancelled} | ${run.skipped} | ${minutes(run.wallMs)} |`);
}
lines.push('');
const p = report.pipeline;
lines.push(`Конвейер: S7 — ${p['S7-code-review'].runs} прогонов по ${p['S7-code-review'].issues} issue, ${p['S7-code-review'].wallMinutes} мин (≈ ${fmt(p['S7-code-review'].perRunMinutes)} мин/прогон); S4 — ${p['S4-spec-review'].runs} прогонов, ${p['S4-spec-review'].wallMinutes} мин.`);
if (report.jobs) {
lines.push('');
lines.push(`Job-минуты: **${report.jobs.totalMinutes}**, из них «Мутанты» ${report.jobs.mutantMinutes} (${fmt(report.jobs.mutantShare, ' %')}).`);
}
lines.push('');
lines.push('<details><summary>По issue</summary>');
lines.push('');
lines.push('| # | Исход | Вход → S7, ч | S7 → S8, ч | Раунды код / ТЗ | S7 постановок |');
lines.push('|---|---|---:|---:|---|---:|');
for (const row of [...i.rows].sort((a, b) => a.number - b.number)) {
lines.push(`| #${row.number} | ${row.stateReason || '—'} | ${fmt(row.leadToS7Ms == null ? null : hours(row.leadToS7Ms))} | ${fmt(row.reviewToMergeMs == null ? null : hours(row.reviewToMergeMs))} | ${row.codeRounds || '—'} / ${row.specRounds || '—'} | ${row.s7Requests} |`);
}
lines.push('');
lines.push('</details>');
// #728: новые разделы — после прежних; прежние строки не меняются.
if (report.tracks) renderTracks(lines, report.tracks);
if (report.drafts) renderDrafts(lines, report.drafts);
if ('stages' in report) renderStages(lines, report.stages);
if ('tokens' in report) renderTokens(lines, report.tokens);
if (report.compare) renderCompare(lines, report.compare);
return `${lines.join('\n')}\n`;
}
const h = (value) => fmt(value);
const hoursOf = (ms) => (ms == null ? null : hours(ms));
const isoDay = (value) => String(value || '').slice(0, 10);
function renderTracks(lines, t) {
lines.push('');
lines.push('### По трекам');
lines.push('');
lines.push(`Задачи с первым \`S8-merged\` в окне: **${t.n}**. Трек — на момент первого \`S8-merged\` (прежние \`small\`/\`trivial\` — show, без метки — ask для продукта и show для инфраструктуры, §5.1). Отрезки — от первой статусной метки до первого \`S8-merged\`, сумма равна lead: queue — \`S1\`/\`S2\`/\`S5\`; spec — \`S3\` до первого возврата с ревью ТЗ; work — \`S6\` до первого возврата с код-ревью; review — \`S4\`/\`S7\` вместе с ожиданием Validate; rework — \`S3\`/\`S6\` после возврата; blocked — под \`blocked\`, вычитается из отрезка под ним.`);
lines.push('');
lines.push('| Трек | n | lead, ч | queue | spec | work | review | rework | blocked | Находки ship |');
lines.push('|---|---:|---:|---:|---:|---:|---:|---:|---:|---|');
for (const row of t.byTrack) {
const m = row.medians;
const ship = row.ship
? `покрыто ${row.ship.covered} из ${row.ship.issues}, документов ${row.ship.docs}${row.ship.docs ? `: High ${row.ship.high} · Medium ${row.ship.medium} · Low ${row.ship.low}` : ''}`
: '—';
lines.push(`| ${row.track} | ${row.n} | ${h(m.lead)} | ${h(m.queue)} | ${h(m.spec)} | ${h(m.work)} | ${h(m.review)} | ${h(m.rework)} | ${h(m.blocked)} | ${ship} |`);
}
lines.push('');
lines.push('Медианы в часах. Возвраты и раунды — по треку на момент события. Возврат — `S7 → S6`/`S3` или `S4 → S3`; причина — последний комментарий с признаком между постановкой ревью и возвратом, без признака — `unknown`. Раунд — объявление вердикта этапа в комментарии.');
lines.push('');
lines.push(`| Трек | Возвратов | ${RETURN_REASONS.join(' | ')} | Код-ревью: блок. / зел. | Ревью ТЗ: блок. / зел. |`);
lines.push(`|---|---:|${RETURN_REASONS.map(() => '---:').join('|')}|---|---|`);
for (const row of t.events) {
lines.push(`| ${row.track} | ${row.returns} | ${RETURN_REASONS.map((reason) => row.reasons[reason]).join(' | ')} | ${row.rounds.code.blocking} / ${row.rounds.code.green} | ${row.rounds.spec.blocking} / ${row.rounds.spec.green} |`);
}
if (t.changed.length) {
lines.push('');
lines.push(`Трек менялся: ${t.changed.map((row) => `#${row.number} ${row.path.join('→')}`).join(', ')}.`);
}
if (t.truncated.length) {
lines.push('');
lines.push(`Таймлайн усечён (больше ${TIMELINE_PAGE_CAP} страниц), в отрезки не входит: ${t.truncated.map((n) => `#${n}`).join(', ')}.`);
}
if (t.rows.length) {
lines.push('');
lines.push('<details><summary>По задачам (треки)</summary>');
lines.push('');
lines.push('| # | Трек | Путь | Исход | lead, ч | work | review | rework | Возвраты | Объём | Находки ship |');
lines.push('|---|---|---|---|---:|---:|---:|---:|---|---:|---|');
for (const row of t.rows) {
if (row.truncated) {
lines.push(`| #${row.number} | таймлайн усечён | — | ${row.stateReason || '—'} | — | — | — | — | — | ${fmt(row.volume)} | — |`);
continue;
}
const returns = row.returns.length ? row.returns.map((ret) => ret.reason).join(', ') : '—';
const ship = row.ship ? (row.ship.docs.length ? `H ${row.ship.high} · M ${row.ship.medium} · L ${row.ship.low}` : 'не ревьюирована') : '—';
lines.push(`| #${row.number} | ${row.track} | ${row.path.join('→')} | ${row.stateReason || '—'} | ${h(hoursOf(row.leadMs))} | ${h(hoursOf(row.segments.work))} | ${h(hoursOf(row.segments.review))} | ${h(hoursOf(row.segments.rework))} | ${returns} | ${fmt(row.volume)} | ${ship} |`);
}
lines.push('');
lines.push('</details>');
}
}
function renderDrafts(lines, d) {
lines.push('');
lines.push('### Черновик ТЗ (#729)');
lines.push('');
lines.push(`Задачи с первым \`S5-ready\` в окне: **${d.tasks}**. Эпоха \`S4-spec-review\` — от постановки \`S4\` до следующей статусной метки; в счёт идут эпохи на треке ask (трек — на момент начала эпохи). Черновик — комментарий «Черновик:» (§7.2) внутри эпохи; исход — \`S5-ready\` (черновик пошёл в дело) или \`S3-spec\` (выброшен).`);
lines.push('');
const e = d.epochs;
if (!e.withDraft) {
lines.push(`Черновиков нет: эпох \`S4-spec-review\` на ask — ${e.total}, комментария «Черновик:» нет ни в одной.`);
} else {
lines.push('| Эпох S4 (ask) | С черновиком | Пошёл в дело (S5) | Выброшен (S3) | Открыта или иначе |');
lines.push('|---:|---:|---:|---:|---:|');
lines.push(`| ${e.total} | ${e.withDraft} | ${e.used} | ${e.thrown} | ${e.other} |`);
}
lines.push('');
const commits = d.specDraftCommits;
lines.push(commits.count
? `Коммиты с трейлером \`Spec-Draft:\` в \`dev\` за окно: ${commits.count}${commits.issues.length ? ` (${commits.issues.map((n) => `#${n}`).join(', ')})` : ''}.`
: 'Коммитов с трейлером `Spec-Draft:` в `dev` за окно нет.');
lines.push('');
lines.push(`S5 → S7 — от первого \`S5-ready\` до первого \`S7-code-review\` после него, медиана в часах; трек — на момент S5; «с черновиком» — эпоху \`S4\`, закрытую этим \`S5-ready\`, вёл черновик. n < ${MIN_COHORT} — мало данных.`);
lines.push('');
lines.push('| Трек | С черновиком: n | S5 → S7, ч | Без черновика: n | S5 → S7, ч |');
lines.push('|---|---:|---|---:|---|');
const cell = (group) => (group.enough ? h(group.hours) : 'мало данных');
for (const row of d.s5ToS7) lines.push(`| ${row.track} | ${row.draft.n} | ${cell(row.draft)} | ${row.plain.n} | ${cell(row.plain)} |`);
if (d.noS7.length) {
lines.push('');
lines.push(`Без \`S7-code-review\` к концу окна (в S5 → S7 не входят): ${d.noS7.map((n) => `#${n}`).join(', ')}.`);
}
}
function renderStages(lines, s) {
lines.push('');
lines.push('### Job-минуты по стадиям');
lines.push('');
if (!s) {
lines.push('Нет данных: jobs прогонов не запрашивались.');
return;
}
if (!s.process) {
lines.push('Конвейер: нет данных (jobs недоступны или прогонов нет).');
} else {
const tracks = Object.keys(s.process.byTrack);
lines.push(`Конвейер, ${s.process.runs} прогонов с jobs; трек — задачи на момент прогона.`);
lines.push('');
lines.push(`| Стадия | Всего, мин | ${tracks.join(' | ')} |`);
lines.push(`|---|---:|${tracks.map(() => '---:').join('|')}|`);
for (const stage of [...PIPELINE_STAGES, 'other']) {
if (stage === 'other' && !s.process.stages.other) continue;
lines.push(`| ${stage} | ${s.process.stages[stage]} | ${tracks.map((track) => s.process.byTrack[track][stage]).join(' | ')} |`);
}
}
lines.push('');
lines.push(s.validate
? `Validate: **${s.validate.minutes}** мин за ${s.validate.runs} прогонов (${Object.entries(s.validate.byEvent).map(([event, value]) => `${event} ${value}`).join(', ') || '—'}).`
: 'Validate: нет данных (jobs недоступны или прогонов нет).');
if (s.truncated) {
lines.push('');
lines.push(`Jobs запрошены не для всех прогонов — усечено: ${s.truncated.fetched} из ${s.truncated.total} прогонов.`);
}
if (s.unavailable) {
lines.push('');
lines.push(`Jobs недоступны у ${s.unavailable} из ${s.fetched} прогонов — в минуты не входят.`);
}
}
function renderTokens(lines, tokens) {
lines.push('');
lines.push('### Токены');
lines.push('');
if (tokens?.window) {
lines.push('Документы ревью, добавленные в `dev` за окно отчёта: дата коммиттера добавившего коммита, перенос в архив `legacy/reviews/` — не добавление.');
lines.push('');
}
lines.push(tokens?.docs
? `Токены по ${tokens.docs} документам ревью: ${USAGE_KEYS.map((key) => `${key} ${tokens.totals[key]}`).join(' · ')}.`
: `${TOKENS_NO_DATA}.`);
if (tokens?.missing) {
lines.push('');
lines.push(`Без данных о расходе: ${tokens.missing}.`);
}
}
function renderCompare(lines, c) {
lines.push('');
lines.push(`### До и после ${c.cutover}`);
lines.push('');
lines.push(`Первый \`S8-merged\` в [${isoDay(c.before.from)}, ${isoDay(c.before.to)}) и [${isoDay(c.after.from)}, ${c.after.to ? isoDay(c.after.to) : '…'}), окно ${c.days} дн. Когорта — трек на момент S8 и объём задачи: \`+/−\` строк классов A и B (продукт, тесты, инструменты) в коммитах \`Issue: #NN\`, кроме коммитов кандидата беты или релиза и бота \`beta-derived\`; документация, документы ревью и бандл не входят (корзины ${VOLUME_BUCKETS.join(', ')}). Медианы в часах «до → после (разница)»; n < ${MIN_COHORT} на любой стороне — мало данных. Job-минуты в сравнение не входят.`);
lines.push('');
if (!c.cohorts.length) {
lines.push('Мало данных: в окне сравнения нет задач с коммитами.');
} else {
lines.push('| Когорта | n до | n после | lead | work | review | rework | Возвратов (среднее) |');
lines.push('|---|---:|---:|---|---|---|---|---|');
const pair = (cohort, name) => `${fmt(cohort.before[name])} → ${fmt(cohort.after[name])} (${cohort.diff[name] == null ? '—' : `${cohort.diff[name] > 0 ? '+' : ''}${fmt(cohort.diff[name])}`})`;
for (const cohort of c.cohorts) {
if (!cohort.enough) {
lines.push(`| ${cohort.key} | ${cohort.before.n} | ${cohort.after.n} | мало данных | | | | |`);
continue;
}
lines.push(`| ${cohort.key} | ${cohort.before.n} | ${cohort.after.n} | ${['lead', 'work', 'review', 'rework', 'returns'].map((name) => pair(cohort, name)).join(' | ')} |`);
}
}
if (c.noCommits.length) {
lines.push('');
lines.push(`Без коммитов с трейлером или только с документами ревью (объёма нет, в когорты не входят): ${c.noCommits.map((n) => `#${n}`).join(', ')}.`);
}
}
// ---------------------------------------------------------------------------
// gh-обвязка: только чтение.
function ghJson(args) {
return JSON.parse(execFileSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }));
}
/**
* Снимок для отчёта: только чтение GitHub API и локального git.
*
* - issue `state=all` с `since` самого раннего окна (неделя отчёта или окно
* сравнения К7): задачи в `S8-merged` закрываются только с бетой (§2.8).
* Прежняя выборка (`issues`) — закрытые в окне, как раньше;
* - таймлайн — у прежней выборки, у задач с `S5-ready`…`S8-merged` или закрытых и у
* задач прогонов конвейера окна; не дальше `TIMELINE_PAGE_CAP` страниц,
* иначе задача помечается «таймлайн усечён»;
* - jobs (К5) — для `jobRuns`, недоступные jobs — `null`;
* - имена документов ревью, тексты `SHIP-REVIEW-*.md` и документов со строкой
* расхода, дата добавления каждого документа в dev (#761), коммиты
* `origin/dev` с `--numstat` за окно сравнения.
*/
export function fetchSnapshot({ repo, since, until, gh = ghJson, git = null, compare = {} }) {
const sinceIso = new Date(since).toISOString();
const untilMs = at(until);
const cutover = toMs(compare.cutover ?? TRACKS_CUTOVER);
const days = Number(compare.days ?? COMPARE_DAYS);
const earliestIso = new Date(Math.min(at(sinceIso), cutover - days * DAY_MS)).toISOString();
const all = [];
for (let page = 1; page <= 10; page++) {
const batch = gh(['api', `repos/${repo}/issues?state=all&since=${encodeURIComponent(earliestIso)}&per_page=100&page=${page}`]);
if (!Array.isArray(batch) || !batch.length) break;
all.push(...batch.filter((issue) => !issue.pull_request));
if (batch.length < 100) break;
}
const closedInWindow = (issue) => {
const closed = at(issue.closed_at);
return Number.isFinite(closed) && closed >= at(sinceIso) && closed <= untilMs;
};
const issues = all.filter(closedInWindow);
const runs = [];
for (let page = 1; page <= 15; page++) {
const batch = gh(['api', `repos/${repo}/actions/runs?created=${encodeURIComponent(`${sinceIso.slice(0, 10)}..${new Date(untilMs).toISOString().slice(0, 10)}`)}&per_page=100&page=${page}`]);
const rows = batch?.workflow_runs || [];
runs.push(...rows);
if (rows.length < 100) break;
}
const { selected } = jobRuns(runs);
const runIssues = new Set(selected.map((run) => Number(PROCESS_TITLE.exec(String(run.display_title || ''))?.[1])).filter(Number.isFinite));
// #752: задачи в S5–S7 — раздел «Черновик ТЗ» считает их по первому S5-ready.
const allIssues = all.filter((issue) => closedInWindow(issue)
|| (issue.labels || []).some((label) => ['S5-ready', 'S6-in-progress', 'S7-code-review', 'S8-merged'].includes(label?.name ?? label))
|| (issue.state === 'closed' && at(issue.closed_at) >= at(earliestIso))
|| runIssues.has(Number(issue.number)));
const timelines = new Map();
const timelineTruncated = new Set();
for (const issue of allIssues) {
const events = [];
for (let page = 1; page <= TIMELINE_PAGE_CAP; page++) {
const batch = gh(['api', `repos/${repo}/issues/${issue.number}/timeline?per_page=100&page=${page}`, '-H', 'Accept: application/vnd.github+json']);
if (!Array.isArray(batch) || !batch.length) break;
events.push(...batch);
if (batch.length < 100) break;
if (page === TIMELINE_PAGE_CAP) timelineTruncated.add(Number(issue.number));
}
timelines.set(Number(issue.number), events);
}
const jobsByRun = new Map();
for (const run of selected) {
try {
const jobs = [];
for (let page = 1; page <= 3; page++) {
const batch = gh(['api', `repos/${repo}/actions/runs/${run.id}/jobs?per_page=100&page=${page}`]);
const rows = batch?.jobs || [];
jobs.push(...rows);
if (rows.length < 100) break;
}
jobsByRun.set(run.id, jobs);
} catch {
jobsByRun.set(run.id, null); // 403, истёк срок хранения: «нет данных», а не ноль
}
}
let reviewFiles = [];
const reviewDocs = [];
let reviewDocAdded = null;
let commits = [];
if (git) {
const listing = git(['ls-tree', '-r', '--name-only', 'HEAD', '--', 'docs/reviews', 'legacy/reviews']);
reviewFiles = reviewDocNames(listing);
const paths = String(listing).split('\n').map((path) => path.trim()).filter(Boolean);
let usage = [];
try {
usage = git(['grep', '-l', '-e', 'hp:usage', 'HEAD', '--', 'docs/reviews', 'legacy/reviews'])
.split('\n').map((line) => line.trim().replace(/^HEAD:/, '')).filter(Boolean);
} catch { /* git grep без совпадений выходит с кодом 1 */ }
for (const path of new Set([...paths.filter((p) => SHIP_DOC.test(p)), ...usage])) {
reviewDocs.push({ path, text: git(['show', `HEAD:${path}`]) });
}
// #761: окно токенов — по коммиту, добавившему документ в dev; с -M перенос
// в архив (#682) — переименование, а не добавление.
reviewDocAdded = reviewDocAddedAt(git(['log', '-M', '--diff-filter=AR', '--reverse', '--name-status',
'--format=%x1e%cI', 'HEAD', '--', 'docs/reviews', 'legacy/reviews']));
let ref = 'origin/dev';
try { git(['rev-parse', '--verify', '-q', ref]); } catch { ref = 'HEAD'; }
// Коммиты задачи бывают раньше её S8: запас в одно окно до начала сравнения.
commits = readCommits(git, { ref, since: at(earliestIso) - days * DAY_MS });
}
return { issues, allIssues, timelines, timelineTruncated, runs, jobsByRun, reviewFiles, reviewDocs, reviewDocAdded, commits };
}
if (isMainModule(import.meta.url)) {
const arg = (name, fallback) => process.argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3) ?? fallback;
const repo = arg('repo', process.env.GITHUB_REPOSITORY);
const compare = { cutover: arg('compare', TRACKS_CUTOVER), days: Number(arg('compare-days', String(COMPARE_DAYS))) };
if (!repo || !Number.isFinite(at(compare.cutover)) || !(compare.days > 0)) {
console.error('usage: process-metrics.mjs --repo=<owner/repo> [--days=7] [--until=ISO] [--output=file.md] [--json=file.json] [--compare=YYYY-MM-DD] [--compare-days=28]');
process.exit(2);
}
const until = arg('until', new Date().toISOString());
const days = Number(arg('days', '7'));
const since = new Date(at(until) - days * 86_400_000).toISOString();
const git = (args) => execFileSync('git', args, { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
const snapshot = fetchSnapshot({ repo, since, until, git, compare });
// #728: jobs передаёт только CLI — `buildReport` без них job-минут не выдумывает.
const report = buildReport({ since, until, ...snapshot, compare });
const markdown = renderMarkdown(report);
const output = arg('output');
if (output) { mkdirSync(dirname(output), { recursive: true }); writeFileSync(output, markdown, 'utf8'); }
const json = arg('json');
if (json) { mkdirSync(dirname(json), { recursive: true }); writeFileSync(json, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); }
process.stdout.write(markdown);
}