diff --git a/.github/workflows/process-metrics.yml b/.github/workflows/process-metrics.yml new file mode 100644 index 00000000..dfbdc10c --- /dev/null +++ b/.github/workflows/process-metrics.yml @@ -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 diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 0cea5a46..6812f806 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -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', diff --git a/scripts/process-metrics.mjs b/scripts/process-metrics.mjs new file mode 100644 index 00000000..f22c560d --- /dev/null +++ b/scripts/process-metrics.mjs @@ -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= --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--r.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('
По issue'); + 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('
'); + 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= [--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); +} diff --git a/test/process-metrics.test.mjs b/test/process-metrics.test.mjs new file mode 100644 index 00000000..32399945 --- /dev/null +++ b/test/process-metrics.test.mjs @@ -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'); +});