ci(metrics): еженедельный замер процесса одним скриптом (#637)

scripts/process-metrics.mjs — чистые функции над снимками GitHub: по issue
(таймлайн меток) вход по первой статусной метке, S4→S5, вход→S7, S7→S8,
повторные постановки S7; раунды ревью — по документам docs/reviews, не по
событиям метки (конвейер с #636 ставит S7 сам); прогоны Actions по workflow —
исходы, wall-time, события (прогоны конвейера сведены в одну строку); минуты
S4/S7 конвейера без skipped; при наличии jobs — job-минуты и доля «Мутанты».
Markdown-отчёт со сводкой и таблицей по issue; CLI на gh (только чтение).

process-metrics.yml — понедельник 05:00 UTC и по кнопке; отчёт в step summary
и артефакт на 90 дней; прав на запись нет.

Тесты на фикстурах, в т. ч. воспроизведение формы аудита 22.09 (30 issue:
15 r1/13 r2/2 r3 → 1,57; Validate 226/178/37/11). Мутанты
metrics-count-skipped-pipeline-runs, metrics-rounds-by-s7-events.

Issue: #637
User-Visible: no
This commit is contained in:
Claude
2026-09-23 11:42:37 +03:00
parent 0b55163dc3
commit 46edcb1f37
4 changed files with 491 additions and 0 deletions
+291
View File
@@ -0,0 +1,291 @@
#!/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 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,
};
}
/** Раунды ревью по именам документов `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: hours(median(completed.map((i) => i.leadToS7Ms)) ?? NaN) || null,
medianReviewToMergeHours: hours(median(completed.map((i) => i.reviewToMergeMs)) ?? NaN) || null,
medianLeadToS8Hours: hours(median(completed.map((i) => i.leadToS8Ms)) ?? NaN) || null,
medianSpecLeadHours: hours(median(completed.map((i) => i.specLeadMs)) ?? NaN) || null,
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
? git(['ls-tree', '--name-only', 'HEAD:docs/reviews']).split('\n').filter(Boolean)
: [];
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);
}