mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Волна 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
307 lines
16 KiB
JavaScript
307 lines
16 KiB
JavaScript
#!/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);
|
||
}
|