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
+60
View File
@@ -0,0 +1,60 @@
name: Метрики процесса
# #637: еженедельный замер вместо ощущений — lead time S→S7→S8, раунды ревью,
# прогоны Validate по исходам, минуты конвейера в S4/S7. Только чтение: отчёт
# идёт в step summary и артефакт; в issue и репозиторий ничего не пишется.
# Цифры аудита 22.09 (1,57 раунда код-ревью, S7 ≈ 36 мин/раунд, 226 Validate
# за неделю) были собраны руками за час — теперь они стоят один запуск.
on:
schedule:
# Понедельник 05:00 UTC — после ночных прогонов, до рабочего дня.
- cron: '0 5 * * 1'
workflow_dispatch:
inputs:
days:
description: "Окно в днях"
required: false
default: "7"
type: string
permissions:
contents: read
actions: read
issues: read
jobs:
metrics:
name: "Снимок недели: issue, раунды, прогоны"
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
# Код — из dev, как у reconcile: расписание читается из main, а исполняется
# версия, которую проверил CI.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: dev
fetch-depth: 1
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
- name: Собрать отчёт
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
REPO: ${{ github.repository }}
DAYS: ${{ inputs.days || '7' }}
run: |
mkdir -p artifacts/process-metrics
node scripts/process-metrics.mjs --repo="$REPO" --days="$DAYS" \
--output=artifacts/process-metrics/report.md \
--json=artifacts/process-metrics/report.json > /dev/null
cat artifacts/process-metrics/report.md >> "$GITHUB_STEP_SUMMARY"
- name: Сохранить отчёт
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: process-metrics-${{ github.run_id }}
path: artifacts/process-metrics
if-no-files-found: error
retention-days: 90
+24
View File
@@ -8532,6 +8532,30 @@ const MUTANT_DEFINITIONS = [
replace: " if (false && run.pendingValidate === 'active') {",
}],
},
// #637: метрики процесса — цифры, по которым судят об ускорении; ошибка счёта
// хуже отсутствия счёта.
{
id: 'metrics-count-skipped-pipeline-runs',
guard: 'node --test --test-name-pattern="#637 pipelineMetrics" test/process-metrics.test.mjs',
because: 'process.yml runs on every label event and most runs are skipped by the guard; counting '
+ 'them as review rounds would inflate S7 minutes and per-run averages (#637)',
patches: [{
file: 'scripts/process-metrics.mjs',
find: " if (!match || run.conclusion === 'skipped') continue;",
replace: " if (!match) continue; // mutant: skipped runs counted",
}],
},
{
id: 'metrics-rounds-by-s7-events',
guard: 'node --test --test-name-pattern="#637 buildReport" test/process-metrics.test.mjs',
because: 'review rounds come from review documents, not from S7 label events — the pipeline '
+ 're-applies S7 itself after a pending Validate (#636), so events would overcount (#637)',
patches: [{
file: 'scripts/process-metrics.mjs',
find: " const codeRounds = completed.map((issue) => rounds.get(`CODE:${issue.number}`)).filter(Boolean);",
replace: " const codeRounds = completed.map((issue) => issue.s7Requests).filter(Boolean); // mutant: rounds = S7 events",
}],
},
{
id: 'process-reconcile-restarts-healthy-run',
guard: 'node --test test/process-reconcile.test.mjs',
+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);
}
+116
View File
@@ -0,0 +1,116 @@
// #637: еженедельный замер процесса — чистые функции над снимками GitHub.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
buildReport, issueMetrics, jobMinutes, pipelineMetrics, renderMarkdown, reviewRounds, runMetrics,
} from '../scripts/process-metrics.mjs';
const T = (h) => new Date(Date.UTC(2026, 8, 15, 0, Math.round(h * 60))).toISOString();
const labeled = (name, h) => ({ event: 'labeled', label: { name }, created_at: T(h) });
test('#637 issueMetrics: вход по первой статусной метке, S7/S8 по первой постановке, повторы считаются отдельно', () => {
const m = issueMetrics({ number: 600, title: 't', closed_at: T(30), state_reason: 'completed' }, [
labeled('bug', 0), labeled('S2-analysis', 1), labeled('S3-spec', 2), labeled('S4-spec-review', 3),
labeled('S5-ready', 5), labeled('S6-in-progress', 6), labeled('S7-code-review', 10),
{ event: 'unlabeled', label: { name: 'S7-code-review' }, created_at: T(11) },
labeled('S7-code-review', 12), labeled('S8-merged', 20),
]);
assert.equal(m.enteredAt, Date.parse(T(1)), 'bug — не статус');
assert.equal(m.leadToS7Ms, 9 * 3_600_000);
assert.equal(m.reviewToMergeMs, 10 * 3_600_000);
assert.equal(m.leadToS8Ms, 19 * 3_600_000);
assert.equal(m.specLeadMs, 2 * 3_600_000);
assert.equal(m.s7Requests, 2);
assert.equal(m.s4Requests, 1);
const bare = issueMetrics({ number: 1, closed_at: 'x', state_reason: 'not_planned' }, []);
assert.equal(bare.leadToS7Ms, null);
assert.equal(bare.closedAt, null);
});
test('#637 reviewRounds: максимум раунда по документам, отдельно CODE и SPEC', () => {
const rounds = reviewRounds(['CODE-REVIEW-600-r1.md', 'CODE-REVIEW-600-r2.md', 'SPEC-REVIEW-600-r1.md', 'CODE-REVIEW-601-r1.md', 'README.md', 'CODE-REVIEW-issue-5.md']);
assert.equal(rounds.get('CODE:600'), 2);
assert.equal(rounds.get('SPEC:600'), 1);
assert.equal(rounds.get('CODE:601'), 1);
assert.equal(rounds.size, 3);
});
const run = (over) => ({
name: 'Проверка (CI)', event: 'push', conclusion: 'success', run_started_at: T(0), updated_at: T(1), ...over,
});
test('#637 runMetrics: по workflow — исходы, wall-time, события; прогоны конвейера сведены в одну строку', () => {
const rows = runMetrics([
run(), run({ conclusion: 'failure', event: 'workflow_dispatch' }), run({ conclusion: 'cancelled', updated_at: T(0) }),
run({ name: 'Ревью-конвейер', display_title: 'process #600 · S7-code-review · x', event: 'issues', updated_at: T(2) }),
run({ name: 'Ревью-конвейер', display_title: 'process #600 · bug · x', event: 'issues', conclusion: 'skipped', updated_at: T(0) }),
]);
const validate = rows.find((r) => r.workflow === 'Проверка (CI)');
assert.deepEqual([validate.runs, validate.success, validate.failure, validate.cancelled], [3, 1, 1, 1]);
assert.equal(validate.wallMs, 2 * 3_600_000, 'отменённый без длительности не считается');
assert.deepEqual(validate.events, { push: 2, workflow_dispatch: 1 });
const process = rows.find((r) => r.workflow === 'process');
assert.deepEqual([process.runs, process.skipped], [2, 1]);
});
test('#637 pipelineMetrics: минуты S7/S4 по имени прогона, skipped не считаются', () => {
const p = pipelineMetrics([
run({ display_title: 'process #600 · S7-code-review · x', updated_at: T(1) }),
run({ display_title: 'process #600 · S7-code-review · x', updated_at: T(2) }),
run({ display_title: 'process #601 · S7-code-review · x', conclusion: 'skipped', updated_at: T(2) }),
run({ display_title: 'process #602 · S4-spec-review · x', run_started_at: T(0), updated_at: T(0.5) }),
]);
assert.deepEqual(p['S7-code-review'], { runs: 2, issues: 1, wallMinutes: 180, perRunMinutes: 90 });
assert.deepEqual(p['S4-spec-review'], { runs: 1, issues: 1, wallMinutes: 30, perRunMinutes: 30 });
});
test('#637 jobMinutes: доля «Мутанты» по именам job', () => {
const jobs = new Map([[1, [
{ name: 'Мутанты по диффу (1/6): x', started_at: T(0), completed_at: T(1) },
{ name: 'Фронтенд: типы', started_at: T(0), completed_at: T(0.5) },
{ name: 'сломан', started_at: 'x', completed_at: T(1) },
]]]);
assert.deepEqual(jobMinutes(jobs), { totalMinutes: 90, mutantMinutes: 60, mutantShare: 67 });
assert.deepEqual(jobMinutes(new Map()), { totalMinutes: 0, mutantMinutes: 0, mutantShare: null });
});
test('#637 buildReport + renderMarkdown: сводка воспроизводит цифры аудита на фикстуре', () => {
// Форма аудита 22.09: 30 issue с код-ревью — 15 r1, 13 r2, 2 r3 → 1.57; Validate 226 прогонов.
const issues = [];
const timelines = new Map();
const reviewFiles = [];
for (let n = 1; n <= 30; n++) {
issues.push({ number: n, title: `t${n}`, closed_at: T(48), state_reason: 'completed' });
timelines.set(n, [labeled('S1-new', 0), labeled('S7-code-review', 24), labeled('S8-merged', 30)]);
const rounds = n <= 15 ? 1 : n <= 28 ? 2 : 3;
for (let r = 1; r <= rounds; r++) reviewFiles.push(`CODE-REVIEW-${n}-r${r}.md`);
}
issues.push({ number: 99, title: 'dropped', closed_at: T(48), state_reason: 'not_planned' });
const runs = Array.from({ length: 226 }, (_, k) => run({ conclusion: k < 178 ? 'success' : k < 215 ? 'failure' : 'cancelled' }));
const report = buildReport({ since: T(0), until: T(48), issues, timelines, reviewFiles, runs });
assert.equal(report.issues.completed, 30);
assert.equal(report.issues.notPlanned, 1);
assert.equal(Math.round(report.issues.codeReviewRounds.mean * 100) / 100, 1.57);
assert.deepEqual(report.issues.codeReviewRounds.distribution, { 1: 15, 2: 13, 3: 2 });
assert.equal(report.issues.medianLeadToS7Hours, 24);
assert.equal(report.issues.medianReviewToMergeHours, 6);
const validate = report.runs.find((r) => r.workflow === 'Проверка (CI)');
assert.deepEqual([validate.runs, validate.success, validate.failure, validate.cancelled], [226, 178, 37, 11]);
const md = renderMarkdown(report);
assert.match(md, /Закрыто issue: \*\*31\*\* \(completed 30, not_planned 1\)/);
assert.match(md, /Раундов код-ревью на issue \| 1\.57 \(r1: 15, r2: 13, r3: 2\)/);
assert.match(md, /\| Проверка \(CI\) \| 226 \| 178 \| 37 \| 11 \| 0 \| 13560 \|/);
assert.match(md, /\| #99 \| not_planned \| — \| — \| — \/ — \| 0 \|/);
assert.equal(report.jobs, null, 'без jobs job-минуты не выдумываются');
});
test('#637 workflow: еженедельный запуск читает только, публикует summary и artifact', () => {
const wf = readFileSync(new URL('../.github/workflows/process-metrics.yml', import.meta.url), 'utf8');
assert.match(wf, /schedule:\n(?:\s+#[^\n]*\n)*\s+- cron: '/);
assert.match(wf, /workflow_dispatch:/);
assert.match(wf, /permissions:\n\s+contents: read\n\s+actions: read\n\s+issues: read/);
assert.match(wf, /node scripts\/process-metrics\.mjs[\s\S]*--output=artifacts\/process-metrics\/report\.md/);
assert.match(wf, /GITHUB_STEP_SUMMARY/);
assert.ok(!/issues: write/.test(wf), 'метрики ничего не пишут в issue');
});