Files
houseplan-card/scripts/process-metrics.mjs
T
Claudeandclaude[bot] d4a672c715 feat(tools): архив документов ревью выпущенных линий (#682)
Волна 5 эпика #674, инструментальная часть (класс B).
`scripts/reviews-archive.mjs --through=vX.Y.Z` печатает план переноса
документов ревью в `legacy/reviews/<тег>/`, `--apply` делает `git mv` и
пересобирает `docs/reviews/INDEX.md`. Членство — трейлеры `Issue: #NN` в
диапазоне линии, как у манифеста беты (#547) и ревью линии (#638). Правила —
в чистой `archivePlan`: задача уходит в последнюю свою линию; задача с
трейлером после тега остаётся целиком (её раунды ссылаются на прошлые);
закрытая без выпуска уходит с линией, где лёг её документ; документ задачи
без трейлера — с линией, где его добавили; RELEASE-REVIEW — в каталог
своего тега; чужие имена не трогаются. План по v1.77.0: 965 документов
332 задач, 154 остаются в открытой линии.

`legacy/` — класс C в process-gate. Сравнения деревьев с якорем вердикта
(`review-doc-guard.mjs` #499, `task-packet.mjs`) не видят переноса в
`legacy/reviews/`. `process-metrics.mjs` считает раунды по живому каталогу и
архиву. Порог «>900 документов» в тесте индекса снят: в каталоге остаётся
текущая линия. PROCESS.md §2.10 уточнён (правила членства, пустая очередь
S7, ревью линии до переноса), в DEVELOPMENT › Release — шаг чеклиста.
Юнит-тесты и два мутанта (`reviews-archive-moves-open-line-issue`,
`reviews-archive-first-line-wins`).

Issue: #682
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-27 22:10:46 +00:00

307 lines
16 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-журнал).
//
// node scripts/process-metrics.mjs --repo=<owner/repo> --days=7 [--until=ISO] [--output=path.md] [--json=path.json]
import { execFileSync } from 'node:child_process';
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { isMainModule } from './spawn-portable.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 };
}
/** Собрать всё в один отчёт. */
export function buildReport({ since, until, issues = [], timelines = new Map(), reviewFiles = [], runs = [], jobsByRun = 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,
};
}
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>');
return `${lines.join('\n')}\n`;
}
// ---------------------------------------------------------------------------
// gh-обвязка: только чтение.
function ghJson(args) {
return JSON.parse(execFileSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }));
}
export function fetchSnapshot({ repo, since, until, gh = ghJson, git = null }) {
const sinceIso = new Date(since).toISOString();
const untilMs = at(until);
const issues = [];
for (let page = 1; page <= 5; page++) {
const batch = gh(['api', `repos/${repo}/issues?state=closed&since=${encodeURIComponent(sinceIso)}&per_page=100&page=${page}`]);
if (!Array.isArray(batch) || !batch.length) break;
for (const issue of batch) {
if (issue.pull_request) continue;
const closed = at(issue.closed_at);
if (Number.isFinite(closed) && closed >= at(sinceIso) && closed <= untilMs) issues.push(issue);
}
if (batch.length < 100) break;
}
const timelines = new Map();
for (const issue of issues) {
const events = [];
for (let page = 1; page <= 3; 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;
}
timelines.set(Number(issue.number), events);
}
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 reviewFiles = git
? reviewDocNames(git(['ls-tree', '-r', '--name-only', 'HEAD', '--', 'docs/reviews', 'legacy/reviews']))
: [];
return { issues, timelines, runs, reviewFiles };
}
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);
if (!repo) {
console.error('usage: process-metrics.mjs --repo=<owner/repo> [--days=7] [--until=ISO] [--output=file.md] [--json=file.json]');
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' });
const snapshot = fetchSnapshot({ repo, since, until, git });
const report = buildReport({ since, until, ...snapshot });
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);
}