Files
houseplan-card/test/process-metrics.test.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

158 lines
9.4 KiB
JavaScript
Raw Permalink 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.
// #637: еженедельный замер процесса — чистые функции над снимками GitHub.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
buildReport, issueMetrics, jobMinutes, pipelineMetrics, renderMarkdown, reviewDocNames, 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 buildReport: нулевая медиана не смешивается с отсутствием данных', () => {
const issue = { number: 637, title: 'fast', closed_at: T(4 / 60), state_reason: 'completed' };
const timelines = new Map([[637, [
labeled('S1-new', 0),
labeled('S4-spec-review', 0),
labeled('S5-ready', 1 / 60),
labeled('S7-code-review', 2 / 60),
labeled('S8-merged', 3 / 60),
]] ]);
const report = buildReport({ since: T(0), until: T(1), issues: [issue], timelines });
assert.deepEqual([
report.issues.medianLeadToS7Hours,
report.issues.medianReviewToMergeHours,
report.issues.medianLeadToS8Hours,
report.issues.medianSpecLeadHours,
], [0, 0, 0.1, 0]);
assert.match(renderMarkdown(report), /Медиана вход → S7 \| 0 ч/);
const empty = buildReport({ since: T(0), until: T(1) });
assert.equal(empty.issues.medianLeadToS7Hours, null);
assert.match(renderMarkdown(empty), /Медиана вход → S7 \| —/);
});
test('#637 workflow: еженедельный запуск читает только, публикует summary и artifact', () => {
const wf = readFileSync(new URL('../.github/workflows/_process-metrics.yml', import.meta.url), 'utf8');
// #623: расписание — у тонкого вызывающего файла, тело — в `_process-metrics.yml`.
const caller = readFileSync(new URL('../.github/workflows/process-metrics.yml', import.meta.url), 'utf8');
assert.match(caller, /schedule:\n(?:\s+#[^\n]*\n)*\s+- cron: '/);
assert.match(caller, /workflow_dispatch:/);
assert.ok(!/issues: write/.test(caller), 'потолок прав вызывающего тоже без записи в issue');
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');
});
test('#682 reviewDocNames: живой каталог и архив legacy/reviews/<тег>/ считаются вместе', () => {
const listing = [
'docs/reviews/CODE-REVIEW-600-r2.md',
'docs/reviews/INDEX.md',
'legacy/reviews/v1.77.0/CODE-REVIEW-600-r1.md',
'legacy/reviews/v1.77.0/SPEC-REVIEW-601-r1.md',
'legacy/docs/ROADMAP.md',
'',
].join('\n');
const names = reviewDocNames(listing);
assert.deepEqual(names, ['CODE-REVIEW-600-r2.md', 'INDEX.md', 'CODE-REVIEW-600-r1.md', 'SPEC-REVIEW-601-r1.md']);
// Перенос r1 в архив не уменьшает число раундов задачи.
assert.equal(reviewRounds(names).get('CODE:600'), 2);
});