#!/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-журнал). // // // #728: эффект процесса по трекам — трек на момент события, отрезки времени // (работа, ожидание, переделка), возвраты с причинами, находки пакетного ревью // ship, job-минуты по стадиям, токены и сравнение сопоставимых задач до и после // перехода на треки. Определения — в ТЗ #728 и в заголовках разделов отчёта. // Отчёт только читает: признаки конвейера импортируются, а не копируются, где // у конвейера есть константа; где её нет (`validate-red`/`conflict`), копию // текста держит контрактный тест на шаблонах `_process.yml`. // // #761: токены — только за окно отчёта: документ ревью входит в неделю, если // коммит, добавивший его в `dev`, попал в окно (перенос в архив — не добавление). // #752: объём задачи — `+/−` строк классов A и B (продукт, тесты, инструменты) // в её коммитах с трейлером `Issue: #NN`. Не входят коммиты беты, а не задачи: // кандидат беты или релиза (трейлер `Release:` и подпись кандидата или бандл, // `bundle-policy.mjs`) и коммит бота `beta-derived` (#697). Коммиты задачи с // `Release:` — приёмка эталонов, перепривязка тестов к бете — входят. // Документация, документы ревью и бандл в объём не входят: перенос архива // (#682) — не работа над кодом. Возвраты слияния и стража ребейза узнаются по // признакам `merge-candidate.mjs`; раздел «Черновик ТЗ» — эпохи `S4` с // черновиком (#729, §11.8) и S5 → S7 с черновиком и без. // // node scripts/process-metrics.mjs --repo= --days=7 [--until=ISO] [--output=path.md] [--json=path.json] // [--compare=2026-09-28] [--compare-days=28] import { execFileSync } from 'node:child_process'; import { mkdirSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; import { isMainModule } from './spawn-portable.mjs'; import { classify } from './change-classes.mjs'; import { labelTrack, parseNumstat } from './process-track.mjs'; import { issueTrailers } from './release-membership.mjs'; import { USAGE_KEYS, lastUsageIn } from './model-usage.mjs'; import { ANCHOR_MARKER, verdictDeclaration } from './review-doc-guard.mjs'; import { SHIP_REVIEW_ANCHOR, parseAnchorBlock } from './ship-review.mjs'; import { PIPELINE_EVENTS } from './wait-verdict.mjs'; import { OUTCOME_SIGNS, outcomeOf } from './merge-candidate.mjs'; import { committedBundleMustMatch, isReleaseMessage } from './bundle-policy.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--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 }; } // --------------------------------------------------------------------------- // #728: эффект процесса по трекам. const DAY_MS = 86_400_000; /** Решение владельца о треках (PROCESS.md §5, #695): граница сравнения «до и после». */ export const TRACKS_CUTOVER = '2026-09-28'; export const COMPARE_DAYS = 28; /** Минимальная когорта сравнения: меньше — «мало данных», разницы нет. */ export const MIN_COHORT = 3; /** Jobs запрашиваются не больше чем для стольких прогонов окна. */ export const JOBS_RUN_CAP = 600; /** Таймлайн issue читается не дальше стольких страниц по 100 событий. */ export const TIMELINE_PAGE_CAP = 10; export const TRACKS = ['ship', 'show', 'ask']; export const SEGMENTS = ['queue', 'spec', 'work', 'review', 'rework', 'blocked']; export const RETURN_REASONS = ['verdict-yellow', 'verdict-red', 'validate-red', 'conflict', 'push-refused', 'merge', 'reclassify', 'owner-question', 'unknown']; export const PIPELINE_STAGES = ['guard', 'prepare', 'model', 'integrate']; export const VOLUME_BUCKETS = ['≤30', '31–200', '201–1000', '>1000']; export const VALIDATE_WORKFLOW = 'Проверка (CI)'; export const TOKENS_NO_DATA = 'Токены: нет данных (ни один документ ревью не несёт расход модели)'; /** * Причины `validate-red` и `conflict` (К3). У конвейера нет для них отдельной * константы: оба комментария `_process.yml` («Validate красный — вернуть * автору без ревью» и «Конфликт с dev — вернуть автору без ревью») идут под * одним префиксом `PIPELINE_EVENTS` с `kind: 'conflict'`. Причину даёт * продолжение первой строки. Это копия текста шаблонов, а не импорт: от * расхождения её держит контрактный тест на самих шаблонах (AC3 #728). */ export const NOT_RUN_VALIDATE_RE = /^\*\*Ревью не запускалось:\*\* Validate(?: с мутантами)? на материале /m; export const NOT_RUN_CONFLICT_RE = /^\*\*Ревью не запускалось:\*\* ветка \S+ не ребейзится на /m; /** Маршрут вердикта show (#726): машинная строка комментария конвейера. */ export const ROUTE_RE = //; /** Признак конвейера по `kind`: переименование в `wait-verdict.mjs` ломает загрузку, а не молча даёт `unknown`. */ function pipelineEvent(kind) { const event = PIPELINE_EVENTS.find((entry) => entry.kind === kind); if (!event) throw new Error(`process-metrics: в PIPELINE_EVENTS (wait-verdict.mjs) нет kind '${kind}'`); return event.re; } /** Общий префикс «Ревью не запускалось»: семейство, не причина. */ const NOT_RUN_RE = pipelineEvent('conflict'); /** Неудачное слияние после ревью: «Слияние отменено», «Код-ревью зелёное — вердикт выше в силе». */ const MERGE_RES = [pipelineEvent('stale'), pipelineEvent('merge-conflict')]; /** * #752: причина по исходу `merge-candidate.mjs` (`OUTCOME_SIGNS`). Отказ push * стража ребейза до ревью (#705) — `push-refused`: код не читали, действует * автор или владелец правом на workflow. Исход слияния после зелёного вердикта * (кандидат красный или пропал, `dev` ушёл, отказ push, сбой шага) — `merge`: * вердикт был, слить не удалось. `rereview` возвращает задачу в `S7`, а не * автору, — причины не называет. */ if (!OUTCOME_SIGNS.some((sign) => sign.action === 'rereview')) { throw new Error("process-metrics: в OUTCOME_SIGNS (merge-candidate.mjs) нет исхода 'rereview'"); } const outcomeReason = (sign) => (sign.stage === 'rebase' ? 'push-refused' : sign.action === 'rereview' ? null : 'merge'); const toMs = (value) => (typeof value === 'number' ? value : at(value)); /** События меток таймлайна в порядке времени (при равном времени — в порядке таймлайна). */ function labelEvents(events = []) { return (events || []) .map((event, index) => ({ event, index })) .filter(({ event }) => (event?.event === 'labeled' || event?.event === 'unlabeled') && event.label?.name) .map(({ event, index }) => ({ type: event.event, label: event.label.name, at: at(event.created_at), index })) .filter((event) => Number.isFinite(event.at)) .sort((a, b) => a.at - b.at || a.index - b.index); } /** Комментарии таймлайна (`commented`) в порядке времени. */ function commentEvents(events = []) { return (events || []) .map((event, index) => ({ event, index })) .filter(({ event }) => event?.event === 'commented') .map(({ event, index }) => ({ body: String(event.body ?? ''), at: at(event.created_at), index })) .filter((comment) => Number.isFinite(comment.at)) .sort((a, b) => a.at - b.at || a.index - b.index); } /** * Метки на задаче в момент `t`: все `labeled`/`unlabeled` строго до `t` * (`inclusive` — включая события в сам момент `t`). */ export function labelsAt(events, t, { inclusive = false } = {}) { const moment = toMs(t); const labels = new Set(); for (const event of labelEvents(events)) { if (inclusive ? event.at > moment : event.at >= moment) break; if (event.type === 'labeled') labels.add(event.label); else labels.delete(event.label); } return [...labels]; } /** * К1. Трек на момент `t` — по меткам, стоящим на задаче до `t`. Трековые метки * разрешает `process-track.mjs`, одним правилом с конвейером: строжайшая из * нескольких `track:*`; прежние `small`/`trivial` — show; без метки — ask для * продукта, show для инфраструктуры (`infra`, §5.1). */ export function trackAt(events, t, { infra = false } = {}) { return labelTrack({ labels: labelsAt(events, t), infrastructure: infra }); } /** Путь трека: разные треки подряд от `from` (включительно) до `to` (исключая), например `['ship', 'show']`. */ export function trackPath(events, { from, to, infra = false } = {}) { const start = toMs(from); const end = toMs(to); const path = []; const push = (track) => { if (path.at(-1) !== track) path.push(track); }; push(labelTrack({ labels: labelsAt(events, start, { inclusive: true }), infrastructure: infra })); const moments = [...new Set(labelEvents(events).map((event) => event.at))].filter((moment) => moment > start && moment < end); for (const moment of moments) push(labelTrack({ labels: labelsAt(events, moment, { inclusive: true }), infrastructure: infra })); return path; } const SEGMENT_OF_STATUS = { 'S1-new': 'queue', 'S2-analysis': 'queue', 'S5-ready': 'queue', 'S4-spec-review': 'review', 'S7-code-review': 'review', }; /** * К2. Отрезки от первой статусной метки до первого `S8-merged`. Статус в * каждый момент — последняя поставленная статусная метка, `blocked` — * наложение поверх статуса: его время вычитается из отрезка под ним. Возврат — * смена статуса `S7 → S6` (и `S7 → S3`, маршрут reclassify #726) или `S4 → S3`; * постановка `S7` поверх `S7` — не возврат и не новый отрезок. Сумма отрезков * равна `lead`. Без `S8` — `null`. */ export function issueSegments(events = []) { const list = labelEvents(events); const s8 = list.find((event) => event.type === 'labeled' && event.label === 'S8-merged'); if (!s8) return null; const segments = Object.fromEntries(SEGMENTS.map((name) => [name, 0])); const returns = []; let status = null; let enteredAt = null; let reviewSince = null; let blocked = false; let specReturned = false; let codeReturned = false; let cursor = null; const current = () => { if (blocked) return 'blocked'; if (status === 'S3-spec') return specReturned || codeReturned ? 'rework' : 'spec'; if (status === 'S6-in-progress') return codeReturned ? 'rework' : 'work'; return SEGMENT_OF_STATUS[status] || 'queue'; }; for (const event of list) { if (cursor !== null && event.at > cursor) { segments[current()] += event.at - cursor; cursor = event.at; } if (event === s8) break; if (event.label === 'blocked') { blocked = event.type === 'labeled'; continue; } if (event.type !== 'labeled' || !STATUS_LABELS.includes(event.label) || event.label === status) continue; if (status === 'S7-code-review' && (event.label === 'S6-in-progress' || event.label === 'S3-spec')) { codeReturned = true; returns.push({ stage: 'code', from: status, to: event.label, since: reviewSince, at: event.at }); } else if (status === 'S4-spec-review' && event.label === 'S3-spec') { specReturned = true; returns.push({ stage: 'spec', from: status, to: event.label, since: reviewSince, at: event.at }); } status = event.label; if (status === 'S4-spec-review' || status === 'S7-code-review') reviewSince = event.at; if (cursor === null) { cursor = event.at; enteredAt = event.at; } } if (enteredAt === null) enteredAt = s8.at; return { enteredAt, s8At: s8.at, leadMs: s8.at - enteredAt, segments, returns }; } /** Цвет объявленного вердикта по строке `verdictDeclaration`: первый цвет после слова «Вердикт». */ function verdictColour(line) { const match = /(красн)|(жёлт|желт)|(зелён|зелен)/i.exec(String(line).slice(Math.max(0, String(line).indexOf('Вердикт')))); if (!match) return null; return match[1] ? 'red' : match[2] ? 'yellow' : 'green'; } /** * Вердикт этапа в комментарии: объявление (`verdictDeclaration`) и документ * этой задачи и этого этапа `-` — то же правило, что у счёта * циклов конвейера (`stageVerdictComments`). Вердикт о чужом документе — `null`. */ export function stageVerdict(body, { stage = 'code', number } = {}) { if (!/^\d+$/.test(String(number ?? ''))) return null; const marker = stage === 'spec' ? 'SPEC-REVIEW' : 'CODE-REVIEW'; const text = String(body ?? ''); if (!new RegExp(`${marker}-${number}(?![0-9])`).test(text)) return null; const line = verdictDeclaration(text); return line ? verdictColour(line) : null; } /** * К3. Признак причины возврата в одном комментарии; `null` — комментарий * причины не называет. «Ревью не запускалось» с другим продолжением — * `unknown`: семейство узнано, причина не угадывается. Исходы слияния и * стража ребейза — по признакам `merge-candidate.mjs` (#752). */ export function returnSignal(body, { stage = 'code', number } = {}) { const text = String(body ?? ''); const route = ROUTE_RE.exec(text); if (route) return route[1]; const outcome = outcomeOf(text); if (outcome) return outcomeReason(outcome); if (NOT_RUN_RE.test(text)) { if (NOT_RUN_VALIDATE_RE.test(text)) return 'validate-red'; if (NOT_RUN_CONFLICT_RE.test(text)) return 'conflict'; return 'unknown'; } if (MERGE_RES.some((re) => re.test(text))) return 'merge'; const colour = stageVerdict(text, { stage, number }); if (colour === 'red') return 'verdict-red'; if (colour === 'yellow') return 'verdict-yellow'; return null; } /** * Причина возврата — последний комментарий с признаком К3 между постановкой * ревью (`since`) и возвратом (`at`), обе границы включительно. Комментарии * без признака (разговор, «Пока шло ревью…», «Лимит циклов…») причину не * перекрывают. Ничего — `unknown`: метку переставил человек. */ export function returnReason(comments = [], { since, at: returnedAt, stage = 'code', number } = {}) { const from = toMs(since); const to = toMs(returnedAt); const window = (comments || []).filter((comment) => { const moment = toMs(comment.at ?? comment.created_at); return Number.isFinite(moment) && moment >= from && moment <= to; }); for (let i = window.length - 1; i >= 0; i--) { const signal = returnSignal(window[i].body, { stage, number }); if (signal) return signal; } return 'unknown'; } /** Раунды ревью по комментариям: объявление вердикта этапа своей задачи до `until`. */ export function verdictRounds(comments = [], { number, until = Infinity } = {}) { const rounds = []; for (const comment of comments || []) { const moment = toMs(comment.at ?? comment.created_at); if (!Number.isFinite(moment) || moment > toMs(until)) continue; for (const stage of ['code', 'spec']) { const colour = stageVerdict(comment.body, { stage, number }); if (!colour) continue; rounds.push({ stage, colour, blocking: colour !== 'green', at: moment }); break; } } return rounds; } const SHIP_DOC = /^(?:docs\/reviews|legacy\/reviews)\/(?:.*\/)?SHIP-REVIEW-[^/]+\.md$/; /** * К4. Находки пакетного ревью ship по документам `SHIP-REVIEW-*.md` живого * каталога и архива: счёт High/Medium/Low — по документу (по задаче * серьёзность не записывается), у задачи — сумма по документам, чей * машинный блок её называет. */ export function shipFindings(reviewDocs = []) { const byIssue = new Map(); const docs = []; for (const doc of reviewDocs || []) { if (!SHIP_DOC.test(String(doc?.path || ''))) continue; const block = parseAnchorBlock(doc.text); if (!block) continue; const entry = { path: doc.path, issues: block.issues, high: block.high ?? 0, medium: block.medium ?? 0, low: block.low ?? 0 }; docs.push(entry); for (const number of block.issues) { const sum = byIssue.get(number) || { high: 0, medium: 0, low: 0, docs: [] }; sum.high += entry.high; sum.medium += entry.medium; sum.low += entry.low; sum.docs.push(doc.path); byIssue.set(number, sum); } } return { byIssue, docs }; } /** * #737: строка расхода модели документа ревью — только из машинного блока, * который пишет конвейер: после `ANCHOR_MARKER` (ревью ТЗ и кода) или после * последнего `SHIP_REVIEW_ANCHOR` (`SHIP-REVIEW-*`). Проза выше маркера не * источник: ревьюер r2 цитирует документ r1, и расход r1 считался бы дважды. * Формат и разбор — `model-usage.mjs`; строки нет — `null`. */ export function reviewDocUsage(doc) { const text = String(doc?.text ?? ''); const at = SHIP_DOC.test(String(doc?.path || '')) ? text.lastIndexOf(SHIP_REVIEW_ANCHOR) : text.indexOf(ANCHOR_MARKER); return at < 0 ? null : lastUsageIn(text.slice(at)); } /** * К6 (#728, #737). Расход модели по документам ревью: `docs` — документы с * данными, `totals` — суммы по ключам строки (`null`, пока данных нет), * `missing` — документы с `hp:usage-none`. «Нет данных» — не ноль: в суммы * не входит. Документы без строки (до #737) не считаются ни тем, ни другим. * Окно недели (#761) применяет вызывающий: `tokenDocs`. */ export function tokenUsage(reviewDocs = []) { const totals = Object.fromEntries(USAGE_KEYS.map((key) => [key, 0])); let docs = 0; let missing = 0; for (const doc of reviewDocs || []) { const usage = reviewDocUsage(doc); if (!usage) continue; if ('reason' in usage) { missing += 1; continue; } docs += 1; for (const key of USAGE_KEYS) totals[key] += usage[key]; } return { docs, totals: docs ? totals : null, missing }; } /** * #761: дата добавления документа ревью в `dev` по выводу * `git log -M --diff-filter=AR --reverse --name-status --format=%x1e%cI`. * Добавление (`A`) ставит дату коммиттера; переименование (`R`, перенос в * архив `legacy/reviews/` #682) переносит дату на новый путь, а не добавляет * документ заново. → Map путь в `HEAD` → дата (ISO). */ export function reviewDocAddedAt(text = '') { const added = new Map(); for (const record of String(text).split('\x1e')) { const [date = '', ...rows] = record.split('\n'); for (const row of rows) { const [status = '', from, to] = row.split('\t'); if (status === 'A' && from) added.set(from, date.trim()); else if (/^R\d*$/.test(status) && to && added.has(from)) { added.set(to, added.get(from)); added.delete(from); } } } return added; } /** * #761: документы для токенов недели — добавленные в `dev` в `[since, until]` * (`reviewDocAddedAt`). Документ, чьё добавление не найдено, в окно не входит. * Без карты дат (`null`: снимок без git, юнит над готовыми документами) окна * нет, как до #761. */ export function tokenDocs(reviewDocs = [], { added = null, since, until } = {}) { if (!added) return reviewDocs || []; const from = toMs(since); const to = toMs(until); return (reviewDocs || []).filter((doc) => { const moment = at(added.get(String(doc?.path ?? ''))); return moment >= from && moment <= to; }); } /** `git log --format=%x1e%H%x1f%cI%x1f%B%x1f --numstat` → коммиты с трейлерами и строками. */ export function parseGitLog(text = '') { return String(text).split('\x1e').filter((record) => record.trim()).map((record) => { const [sha, date, body = '', numstat = ''] = record.split('\x1f'); return { sha: sha.trim(), date: date?.trim() || null, body, issues: issueTrailers(body), files: parseNumstat(numstat) }; }).filter((commit) => /^[0-9a-f]{7,64}$/.test(commit.sha)); } /** Коммиты `ref` с `since` (дата коммиттера) — с `--numstat`, без переименований. */ export function readCommits(git, { ref = 'origin/dev', since } = {}) { const args = ['log', ref, '--no-renames', '--numstat', '--format=%x1e%H%x1f%cI%x1f%B%x1f']; if (since) args.splice(2, 0, `--since=${new Date(toMs(since)).toISOString()}`); return parseGitLog(git(args)); } /** Строка объёма К7 (#752): классы A и B — продукт, тесты, инструменты. */ const countsToVolume = (path) => ['A', 'B'].includes(classify(path)); /** Файл задачи для признака инфраструктуры (#752): A, B, C, кроме документов ревью — их пишет конвейер. */ const isTaskFile = (path) => ['A', 'B', 'C'].includes(classify(path)) && !String(path).startsWith('docs/reviews/'); /** * Подпись коммита бота `beta-derived` (#697). Константы у конвейера нет — * сообщение собирает `_beta-derived.yml`; копию держит контрактный тест. */ export const BETA_DERIVED_SUBJECT = /^docs: accept derived artifacts on dev for v\d/; /** * Коммит беты, а не задачи (#752): он несёт трейлеры всех задач линии. * Кандидат беты или релиза — трейлер `Release:` и подпись кандидата либо * изменённый бандл (`committedBundleMustMatch`: с #657 бандл меняет только * кандидат); коммит бота `beta-derived` — по подписи. Прочий коммит с * `Release:` (приёмка эталонов, перепривязка тестов к бете) — коммит задачи. */ export function isBetaCommit(commit) { const body = String(commit?.body ?? ''); const subject = body.split('\n', 1)[0]; if (BETA_DERIVED_SUBJECT.test(subject)) return true; return isReleaseMessage(body) && committedBundleMustMatch({ subject, files: (commit?.files || []).map((file) => file.path) }); } /** * Изменения задачи по коммитам с трейлером `Issue: #NN`, кроме коммитов беты * (`isBetaCommit`): объём К7 (`+/−` классов A и B) и файлы задачи для признака * инфраструктуры К1 (A, B, C без `docs/reviews/**`). */ export function issueChanges(commits = []) { const byIssue = new Map(); for (const commit of commits || []) { if (isBetaCommit(commit)) continue; for (const number of commit.issues || issueTrailers(commit.body)) { const entry = byIssue.get(number) || { lines: 0, files: new Set(), commits: 0 }; entry.commits += 1; for (const file of commit.files || []) { if (isTaskFile(file.path)) entry.files.add(file.path); if (countsToVolume(file.path)) entry.lines += (file.added ?? 0) + (file.deleted ?? 0); } byIssue.set(number, entry); } } return byIssue; } /** * Инфраструктура (§1): у задачи есть файлы задачи и ни одного класса A. * Задача только с документами ревью — признак не доказан. */ export const isInfra = (change) => Boolean(change && change.files.size && [...change.files].every((path) => classify(path) !== 'A')); /** Объём задачи К7: строки A+B; без файлов задачи (только документы ревью, бандл) — `null`, объёма нет. */ export const changeVolume = (change) => (change && change.files.size ? change.lines : null); /** Корзина объёма К7: ≤ 30, 31–200, 201–1000, > 1000 строк. */ export function volumeBucket(lines) { if (!Number.isFinite(lines)) return null; if (lines <= 30) return VOLUME_BUCKETS[0]; if (lines <= 200) return VOLUME_BUCKETS[1]; if (lines <= 1000) return VOLUME_BUCKETS[2]; return VOLUME_BUCKETS[3]; } /** * Задача для разделов по трекам: трек на момент первого `S8-merged`, путь * трека, отрезки, возвраты и раунды с треком своего момента, находки ship. * Без `S8` — `null`. Усечённый таймлайн — строка без отрезков (`truncated`). */ export function issueTrackMetrics(issue, events = [], { change = null, ship = null, truncated = false } = {}) { const number = Number(issue.number); const s8 = labelEvents(events).find((event) => event.type === 'labeled' && event.label === 'S8-merged'); if (!s8) return null; const infra = isInfra(change); const volume = changeVolume(change); const base = { number, title: String(issue.title || ''), stateReason: issue.state_reason || null, s8At: s8.at, infra, volume, bucket: volumeBucket(volume), }; if (truncated) return { ...base, truncated: true, track: null, path: [], leadMs: null, segments: null, returns: [], rounds: [], ship: null }; const seg = issueSegments(events); const comments = commentEvents(events); const track = trackAt(events, s8.at, { infra }); return { ...base, truncated: false, track, path: trackPath(events, { from: seg.enteredAt, to: s8.at, infra }), enteredAt: seg.enteredAt, leadMs: seg.leadMs, segments: seg.segments, returns: seg.returns.map((ret) => ({ stage: ret.stage, from: ret.from, to: ret.to, at: ret.at, reason: returnReason(comments, { since: ret.since, at: ret.at, stage: ret.stage, number }), track: trackAt(events, ret.at, { infra }), })), rounds: verdictRounds(comments, { number, until: s8.at }).map((round) => ({ ...round, track: trackAt(events, round.at, { infra }) })), ship: track === 'ship' ? (ship || { high: 0, medium: 0, low: 0, docs: [] }) : null, }; } const trackOrder = (track) => (TRACKS.includes(track) ? TRACKS.indexOf(track) : TRACKS.length); /** * Разделы «По трекам»: задачи по треку на момент S8; возвраты и раунды — по * треку своего момента. Находки ship — сумма по разным документам, которые * называют ship-задачи раздела: документ на две задачи считается один раз. */ export function trackSection(rows = [], { shipDocs = [] } = {}) { const counted = rows.filter((row) => !row.truncated); const tracks = [...new Set([...TRACKS, ...counted.map((row) => row.track)])].sort((a, b) => trackOrder(a) - trackOrder(b)); const byTrack = tracks.map((track) => { const own = counted.filter((row) => row.track === track); const medians = Object.fromEntries(['lead', ...SEGMENTS].map((name) => [name, medianHours(own.map((row) => (name === 'lead' ? row.leadMs : row.segments[name])))])); let ship = null; if (track === 'ship') { const numbers = new Set(own.map((row) => row.number)); const docs = (shipDocs || []).filter((doc) => doc.issues.some((number) => numbers.has(number))); ship = { issues: own.length, covered: own.filter((row) => row.ship?.docs.length).length, docs: docs.length, high: docs.reduce((sum, doc) => sum + doc.high, 0), medium: docs.reduce((sum, doc) => sum + doc.medium, 0), low: docs.reduce((sum, doc) => sum + doc.low, 0), }; } return { track, n: own.length, medians, ship }; }); const events = tracks.map((track) => { const returns = counted.flatMap((row) => row.returns).filter((ret) => ret.track === track); const rounds = counted.flatMap((row) => row.rounds).filter((round) => round.track === track); return { track, returns: returns.length, reasons: Object.fromEntries(RETURN_REASONS.map((reason) => [reason, returns.filter((ret) => ret.reason === reason).length])), rounds: Object.fromEntries(['code', 'spec'].map((stage) => [stage, { blocking: rounds.filter((round) => round.stage === stage && round.blocking).length, green: rounds.filter((round) => round.stage === stage && !round.blocking).length, }])), }; }); return { n: counted.length, byTrack, events, truncated: rows.filter((row) => row.truncated).map((row) => row.number), changed: counted.filter((row) => row.path.length > 1).map((row) => ({ number: row.number, path: row.path })), rows: [...rows].sort((a, b) => a.number - b.number), }; } // --------------------------------------------------------------------------- // #752 П.4: черновик ТЗ (#729, PROCESS.md §11.8). /** * Комментарий «Черновик:» по шаблону §7.2 — пишет автор, не конвейер; копию * держит контрактный тест на PROCESS.md. */ export const DRAFT_COMMENT_RE = /^\s*Черновик:/; /** Трейлер чернового коммита (§11.8) — как читает его `process-gate.mjs`. */ const SPEC_DRAFT_TRAILER = /^Spec-Draft:\s*\S/mi; /** Трек на момент `t`, включая метки, поставленные в сам момент `t` (как начало пути трека). */ const trackDuring = (events, t, infra) => labelTrack({ labels: labelsAt(events, t, { inclusive: true }), infrastructure: infra }); /** * Эпохи `S4-spec-review`: от постановки `S4` до следующей статусной метки с * другим именем — статус как в `issueSegments`, повторная постановка `S4` * эпоху не начинает. `close` — метка, закрывшая эпоху; открытая — `null`. */ export function specEpochs(events = []) { const epochs = []; let status = null; let open = null; for (const event of labelEvents(events)) { if (event.type !== 'labeled' || !STATUS_LABELS.includes(event.label) || event.label === status) continue; if (open) epochs.push({ ...open, end: event.at, close: event.label }); status = event.label; open = status === 'S4-spec-review' ? { start: event.at } : null; } if (open) epochs.push({ ...open, end: null, close: null }); return epochs; } /** * Раздел «Черновик ТЗ» по задачам с первым `S5-ready` в окне: * - эпохи `S4` на треке ask (трек — на момент начала эпохи), из них с * комментарием «Черновик:» внутри эпохи и их исход: `S5-ready` — черновик * пошёл в дело, `S3-spec` — выброшен, прочее — открыта или закрыта иначе; * - S5 → S7 — от первого `S5-ready` до первого `S7-code-review` после него * (не позже `until`), по треку на момент S5; «с черновиком» — эпоху `S4`, * закрытую этим `S5-ready`, вёл черновик. n < `MIN_COHORT` — мало данных; * - коммиты `dev` с трейлером `Spec-Draft:` и датой коммиттера в окне. */ export function draftSection({ issues = [], timelines = new Map(), changes = new Map(), commits = [], since, until } = {}) { const from = toMs(since); const to = toMs(until); const inWindow = (moment) => moment >= from && moment <= to; const epochs = { total: 0, withDraft: 0, used: 0, thrown: 0, other: 0 }; const spans = []; const noS7 = []; const tasks = []; const seen = new Set(); for (const issue of issues || []) { const number = Number(issue.number); if (seen.has(number)) continue; seen.add(number); const events = timelines.get(number) || []; const labels = labelEvents(events).filter((event) => event.type === 'labeled'); const s5 = labels.find((event) => event.label === 'S5-ready'); if (!s5 || !inWindow(s5.at)) continue; tasks.push(number); const infra = isInfra(changes.get(number)); const drafts = commentEvents(events).filter((comment) => DRAFT_COMMENT_RE.test(comment.body)); let draftedS5 = false; for (const epoch of specEpochs(events)) { const drafted = drafts.some((comment) => comment.at >= epoch.start && (epoch.end === null || comment.at <= epoch.end)); if (epoch.close === 'S5-ready' && epoch.end === s5.at) draftedS5 = drafted; if (trackDuring(events, epoch.start, infra) !== 'ask') continue; epochs.total += 1; if (!drafted) continue; epochs.withDraft += 1; if (epoch.close === 'S5-ready') epochs.used += 1; else if (epoch.close === 'S3-spec') epochs.thrown += 1; else epochs.other += 1; } const s7 = labels.find((event) => event.label === 'S7-code-review' && event.at >= s5.at); if (!s7 || s7.at > to) { noS7.push(number); continue; } spans.push({ number, track: trackDuring(events, s5.at, infra), drafted: draftedS5, ms: s7.at - s5.at }); } const tracks = [...new Set([...TRACKS, ...spans.map((span) => span.track)])].sort((a, b) => trackOrder(a) - trackOrder(b)); const group = (list) => ({ n: list.length, hours: medianHours(list.map((span) => span.ms)), enough: list.length >= MIN_COHORT }); const drafted = (commits || []).filter((commit) => SPEC_DRAFT_TRAILER.test(String(commit.body || '')) && inWindow(at(commit.date))); return { tasks: tasks.length, epochs, s5ToS7: tracks.map((track) => { const own = spans.filter((span) => span.track === track); return { track, draft: group(own.filter((span) => span.drafted)), plain: group(own.filter((span) => !span.drafted)) }; }), noS7: noS7.sort((a, b) => a - b), specDraftCommits: { count: drafted.length, issues: [...new Set(drafted.flatMap((commit) => commit.issues || []))].sort((a, b) => a - b) }, }; } /** Стадия конвейера по имени job тела — части после « / »: «Страж…», «Ревью: материал…», … */ export function jobStage(name = '') { const text = String(name); const own = text.includes(' / ') ? text.slice(text.indexOf(' / ') + 3) : text; if (/^Страж/.test(own)) return 'guard'; if (/^Ревью: материал/.test(own)) return 'prepare'; if (/^Ревью: работа модели/.test(own)) return 'model'; if (/^Ревью: публикация/.test(own)) return 'integrate'; return 'other'; } const PROCESS_TITLE = /^process #(\d+) · /; /** * Прогоны, для которых запрашиваются jobs (К5): конвейер `process #NN · …` и * Validate, кроме skipped (у них нет ни одной выполненной job), не больше `cap`. */ export function jobRuns(runs = [], cap = JOBS_RUN_CAP) { const eligible = (runs || []).filter((run) => run?.conclusion !== 'skipped' && (PROCESS_TITLE.test(String(run.display_title || '')) || String(run.name || '') === VALIDATE_WORKFLOW)); return { total: eligible.length, selected: eligible.slice(0, cap) }; } const jobMs = (job) => { const started = at(job?.started_at); const ended = at(job?.completed_at); return Number.isFinite(started) && Number.isFinite(ended) && ended > started ? ended - started : 0; }; /** * К5. Job-минуты конвейера по стадиям — всего и по треку задачи на момент * прогона (К1); Validate — всего и по событию. Jobs прогона недоступны * (`null`: 403, истёк срок хранения) — прогон не даёт данных; ни одного * прогона с данными — `null` («нет данных»), а не ноль. */ export function stageMinutes({ runs = [], jobsByRun = new Map(), trackOf = () => null, cap = JOBS_RUN_CAP } = {}) { const { total, selected } = jobRuns(runs, cap); const stagesMs = () => Object.fromEntries([...PIPELINE_STAGES, 'other'].map((stage) => [stage, 0])); const processAll = stagesMs(); const processByTrack = {}; const validateByEvent = {}; let validateMs = 0; let processRuns = 0; let validateRuns = 0; let unavailable = 0; for (const run of selected) { const jobs = jobsByRun.get(run.id) ?? jobsByRun.get(String(run.id)) ?? null; if (!Array.isArray(jobs)) { unavailable += 1; continue; } const process = PROCESS_TITLE.exec(String(run.display_title || '')); if (process) { processRuns += 1; const track = trackOf(Number(process[1]), at(run.run_started_at || run.created_at)) || '—'; processByTrack[track] ||= stagesMs(); for (const job of jobs) { const stage = jobStage(job.name); processAll[stage] += jobMs(job); processByTrack[track][stage] += jobMs(job); } } else { validateRuns += 1; const event = String(run.event || 'other'); for (const job of jobs) { validateMs += jobMs(job); validateByEvent[event] = (validateByEvent[event] || 0) + jobMs(job); } } } const toMinutes = (stages) => Object.fromEntries(Object.entries(stages).map(([stage, ms]) => [stage, minutes(ms)])); return { runs: total, fetched: selected.length, truncated: total > selected.length ? { fetched: selected.length, total } : null, unavailable, process: processRuns ? { runs: processRuns, stages: toMinutes(processAll), byTrack: Object.fromEntries(Object.entries(processByTrack) .sort(([a], [b]) => trackOrder(a) - trackOrder(b)).map(([track, stages]) => [track, toMinutes(stages)])), } : null, validate: validateRuns ? { runs: validateRuns, minutes: minutes(validateMs), byEvent: Object.fromEntries(Object.entries(validateByEvent).map(([event, ms]) => [event, minutes(ms)])), } : null, }; } /** * К7. Сравнение сопоставимых задач до и после `cutover`: первый `S8-merged` * в `[cutover − days, cutover)` и `[cutover, min(cutover + days, until))`, * когорта — трек на момент S8 и корзина объёма. n < `MIN_COHORT` на любой * стороне — «мало данных», разницы нет. Задачи без коммитов с трейлером или * только с документами ревью объёма не имеют и в когорты не входят. */ export function compareCohorts(rows = [], { cutover = TRACKS_CUTOVER, days = COMPARE_DAYS, until = null } = {}) { const border = toMs(cutover); const beforeFrom = border - days * DAY_MS; const afterTo = Math.min(border + days * DAY_MS, until == null ? Infinity : toMs(until)); const sideOf = (row) => { if (row.s8At >= beforeFrom && row.s8At < border) return 'before'; if (row.s8At >= border && row.s8At < afterTo) return 'after'; return null; }; const inWindow = rows.filter((row) => !row.truncated && sideOf(row)); const noCommits = inWindow.filter((row) => row.bucket == null).map((row) => row.number).sort((a, b) => a - b); const cohorts = new Map(); for (const row of inWindow.filter((r) => r.bucket != null)) { const key = `${row.track} · ${row.bucket}`; const cohort = cohorts.get(key) || { key, track: row.track, bucket: row.bucket, before: [], after: [] }; cohort[sideOf(row)].push(row); cohorts.set(key, cohort); } const stats = (side) => ({ n: side.length, lead: medianHours(side.map((row) => row.leadMs)), work: medianHours(side.map((row) => row.segments.work)), review: medianHours(side.map((row) => row.segments.review)), rework: medianHours(side.map((row) => row.segments.rework)), returns: side.length ? Math.round(mean(side.map((row) => row.returns.length)) * 100) / 100 : null, }); const delta = (a, b) => (a == null || b == null ? null : Math.round((b - a) * 100) / 100); const list = [...cohorts.values()] .sort((a, b) => trackOrder(a.track) - trackOrder(b.track) || VOLUME_BUCKETS.indexOf(a.bucket) - VOLUME_BUCKETS.indexOf(b.bucket)) .map((cohort) => { const before = stats(cohort.before); const after = stats(cohort.after); const enough = before.n >= MIN_COHORT && after.n >= MIN_COHORT; return { key: cohort.key, track: cohort.track, bucket: cohort.bucket, before, after, enough, diff: enough ? Object.fromEntries(['lead', 'work', 'review', 'rework', 'returns'].map((name) => [name, delta(before[name], after[name])])) : null, }; }); return { cutover: new Date(border).toISOString().slice(0, 10), days, before: { from: new Date(beforeFrom).toISOString(), to: new Date(border).toISOString() }, after: { from: new Date(border).toISOString(), to: Number.isFinite(afterTo) ? new Date(afterTo).toISOString() : null }, cohorts: list, noCommits, }; } /** Собрать всё в один отчёт. */ export function buildReport({ since, until, issues = [], timelines = new Map(), reviewFiles = [], runs = [], jobsByRun = null, // #728: задачи для разделов по трекам (по умолчанию — те же `issues`), усечённые // таймлайны, тексты документов ревью, коммиты dev и окно сравнения. allIssues = null, timelineTruncated = new Set(), reviewDocs = [], commits = [], compare = {}, // #761: дата добавления документов ревью в dev — окно токенов; `null` — окна нет. reviewDocAdded = 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, ...trackReport({ since, until, issues: allIssues || issues, timelines, timelineTruncated, reviewDocs, reviewDocAdded, runs, jobsByRun, commits, compare }), }; } /** #728: разделы по трекам, job-минуты по стадиям, токены и сравнение до/после. */ function trackReport({ since, until, issues, timelines, timelineTruncated, reviewDocs, reviewDocAdded, runs, jobsByRun, commits, compare }) { const changes = issueChanges(commits); const ship = shipFindings(reviewDocs); const seen = new Set(); const rows = []; for (const issue of issues || []) { const number = Number(issue.number); if (seen.has(number)) continue; seen.add(number); const row = issueTrackMetrics(issue, timelines.get(number) || [], { change: changes.get(number) || null, ship: ship.byIssue.get(number) || null, truncated: timelineTruncated.has(number), }); if (row) rows.push(row); } const from = at(since); const to = at(until); const trackOf = (number, moment) => { const events = timelines.get(number); return events && Number.isFinite(moment) ? trackAt(events, moment, { infra: isInfra(changes.get(number)) }) : null; }; return { tracks: trackSection(rows.filter((row) => row.s8At >= from && row.s8At <= to), { shipDocs: ship.docs }), drafts: draftSection({ issues, timelines, changes, commits, since, until }), stages: jobsByRun ? stageMinutes({ runs, jobsByRun, trackOf }) : null, // #761: токены и `missing` — по документам, добавленным в dev за окно; `window` — окно применено. tokens: reviewDocAdded ? { ...tokenUsage(tokenDocs(reviewDocs, { added: reviewDocAdded, since, until })), window: { since, until } } : tokenUsage(reviewDocs), compare: compareCohorts(rows, { cutover: compare.cutover ?? TRACKS_CUTOVER, days: compare.days ?? COMPARE_DAYS, until }), }; } 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('
'); // #728: новые разделы — после прежних; прежние строки не меняются. if (report.tracks) renderTracks(lines, report.tracks); if (report.drafts) renderDrafts(lines, report.drafts); if ('stages' in report) renderStages(lines, report.stages); if ('tokens' in report) renderTokens(lines, report.tokens); if (report.compare) renderCompare(lines, report.compare); return `${lines.join('\n')}\n`; } const h = (value) => fmt(value); const hoursOf = (ms) => (ms == null ? null : hours(ms)); const isoDay = (value) => String(value || '').slice(0, 10); function renderTracks(lines, t) { lines.push(''); lines.push('### По трекам'); lines.push(''); lines.push(`Задачи с первым \`S8-merged\` в окне: **${t.n}**. Трек — на момент первого \`S8-merged\` (прежние \`small\`/\`trivial\` — show, без метки — ask для продукта и show для инфраструктуры, §5.1). Отрезки — от первой статусной метки до первого \`S8-merged\`, сумма равна lead: queue — \`S1\`/\`S2\`/\`S5\`; spec — \`S3\` до первого возврата с ревью ТЗ; work — \`S6\` до первого возврата с код-ревью; review — \`S4\`/\`S7\` вместе с ожиданием Validate; rework — \`S3\`/\`S6\` после возврата; blocked — под \`blocked\`, вычитается из отрезка под ним.`); lines.push(''); lines.push('| Трек | n | lead, ч | queue | spec | work | review | rework | blocked | Находки ship |'); lines.push('|---|---:|---:|---:|---:|---:|---:|---:|---:|---|'); for (const row of t.byTrack) { const m = row.medians; const ship = row.ship ? `покрыто ${row.ship.covered} из ${row.ship.issues}, документов ${row.ship.docs}${row.ship.docs ? `: High ${row.ship.high} · Medium ${row.ship.medium} · Low ${row.ship.low}` : ''}` : '—'; lines.push(`| ${row.track} | ${row.n} | ${h(m.lead)} | ${h(m.queue)} | ${h(m.spec)} | ${h(m.work)} | ${h(m.review)} | ${h(m.rework)} | ${h(m.blocked)} | ${ship} |`); } lines.push(''); lines.push('Медианы в часах. Возвраты и раунды — по треку на момент события. Возврат — `S7 → S6`/`S3` или `S4 → S3`; причина — последний комментарий с признаком между постановкой ревью и возвратом, без признака — `unknown`. Раунд — объявление вердикта этапа в комментарии.'); lines.push(''); lines.push(`| Трек | Возвратов | ${RETURN_REASONS.join(' | ')} | Код-ревью: блок. / зел. | Ревью ТЗ: блок. / зел. |`); lines.push(`|---|---:|${RETURN_REASONS.map(() => '---:').join('|')}|---|---|`); for (const row of t.events) { lines.push(`| ${row.track} | ${row.returns} | ${RETURN_REASONS.map((reason) => row.reasons[reason]).join(' | ')} | ${row.rounds.code.blocking} / ${row.rounds.code.green} | ${row.rounds.spec.blocking} / ${row.rounds.spec.green} |`); } if (t.changed.length) { lines.push(''); lines.push(`Трек менялся: ${t.changed.map((row) => `#${row.number} ${row.path.join('→')}`).join(', ')}.`); } if (t.truncated.length) { lines.push(''); lines.push(`Таймлайн усечён (больше ${TIMELINE_PAGE_CAP} страниц), в отрезки не входит: ${t.truncated.map((n) => `#${n}`).join(', ')}.`); } if (t.rows.length) { lines.push(''); lines.push('
По задачам (треки)'); lines.push(''); lines.push('| # | Трек | Путь | Исход | lead, ч | work | review | rework | Возвраты | Объём | Находки ship |'); lines.push('|---|---|---|---|---:|---:|---:|---:|---|---:|---|'); for (const row of t.rows) { if (row.truncated) { lines.push(`| #${row.number} | таймлайн усечён | — | ${row.stateReason || '—'} | — | — | — | — | — | ${fmt(row.volume)} | — |`); continue; } const returns = row.returns.length ? row.returns.map((ret) => ret.reason).join(', ') : '—'; const ship = row.ship ? (row.ship.docs.length ? `H ${row.ship.high} · M ${row.ship.medium} · L ${row.ship.low}` : 'не ревьюирована') : '—'; lines.push(`| #${row.number} | ${row.track} | ${row.path.join('→')} | ${row.stateReason || '—'} | ${h(hoursOf(row.leadMs))} | ${h(hoursOf(row.segments.work))} | ${h(hoursOf(row.segments.review))} | ${h(hoursOf(row.segments.rework))} | ${returns} | ${fmt(row.volume)} | ${ship} |`); } lines.push(''); lines.push('
'); } } function renderDrafts(lines, d) { lines.push(''); lines.push('### Черновик ТЗ (#729)'); lines.push(''); lines.push(`Задачи с первым \`S5-ready\` в окне: **${d.tasks}**. Эпоха \`S4-spec-review\` — от постановки \`S4\` до следующей статусной метки; в счёт идут эпохи на треке ask (трек — на момент начала эпохи). Черновик — комментарий «Черновик:» (§7.2) внутри эпохи; исход — \`S5-ready\` (черновик пошёл в дело) или \`S3-spec\` (выброшен).`); lines.push(''); const e = d.epochs; if (!e.withDraft) { lines.push(`Черновиков нет: эпох \`S4-spec-review\` на ask — ${e.total}, комментария «Черновик:» нет ни в одной.`); } else { lines.push('| Эпох S4 (ask) | С черновиком | Пошёл в дело (S5) | Выброшен (S3) | Открыта или иначе |'); lines.push('|---:|---:|---:|---:|---:|'); lines.push(`| ${e.total} | ${e.withDraft} | ${e.used} | ${e.thrown} | ${e.other} |`); } lines.push(''); const commits = d.specDraftCommits; lines.push(commits.count ? `Коммиты с трейлером \`Spec-Draft:\` в \`dev\` за окно: ${commits.count}${commits.issues.length ? ` (${commits.issues.map((n) => `#${n}`).join(', ')})` : ''}.` : 'Коммитов с трейлером `Spec-Draft:` в `dev` за окно нет.'); lines.push(''); lines.push(`S5 → S7 — от первого \`S5-ready\` до первого \`S7-code-review\` после него, медиана в часах; трек — на момент S5; «с черновиком» — эпоху \`S4\`, закрытую этим \`S5-ready\`, вёл черновик. n < ${MIN_COHORT} — мало данных.`); lines.push(''); lines.push('| Трек | С черновиком: n | S5 → S7, ч | Без черновика: n | S5 → S7, ч |'); lines.push('|---|---:|---|---:|---|'); const cell = (group) => (group.enough ? h(group.hours) : 'мало данных'); for (const row of d.s5ToS7) lines.push(`| ${row.track} | ${row.draft.n} | ${cell(row.draft)} | ${row.plain.n} | ${cell(row.plain)} |`); if (d.noS7.length) { lines.push(''); lines.push(`Без \`S7-code-review\` к концу окна (в S5 → S7 не входят): ${d.noS7.map((n) => `#${n}`).join(', ')}.`); } } function renderStages(lines, s) { lines.push(''); lines.push('### Job-минуты по стадиям'); lines.push(''); if (!s) { lines.push('Нет данных: jobs прогонов не запрашивались.'); return; } if (!s.process) { lines.push('Конвейер: нет данных (jobs недоступны или прогонов нет).'); } else { const tracks = Object.keys(s.process.byTrack); lines.push(`Конвейер, ${s.process.runs} прогонов с jobs; трек — задачи на момент прогона.`); lines.push(''); lines.push(`| Стадия | Всего, мин | ${tracks.join(' | ')} |`); lines.push(`|---|---:|${tracks.map(() => '---:').join('|')}|`); for (const stage of [...PIPELINE_STAGES, 'other']) { if (stage === 'other' && !s.process.stages.other) continue; lines.push(`| ${stage} | ${s.process.stages[stage]} | ${tracks.map((track) => s.process.byTrack[track][stage]).join(' | ')} |`); } } lines.push(''); lines.push(s.validate ? `Validate: **${s.validate.minutes}** мин за ${s.validate.runs} прогонов (${Object.entries(s.validate.byEvent).map(([event, value]) => `${event} ${value}`).join(', ') || '—'}).` : 'Validate: нет данных (jobs недоступны или прогонов нет).'); if (s.truncated) { lines.push(''); lines.push(`Jobs запрошены не для всех прогонов — усечено: ${s.truncated.fetched} из ${s.truncated.total} прогонов.`); } if (s.unavailable) { lines.push(''); lines.push(`Jobs недоступны у ${s.unavailable} из ${s.fetched} прогонов — в минуты не входят.`); } } function renderTokens(lines, tokens) { lines.push(''); lines.push('### Токены'); lines.push(''); if (tokens?.window) { lines.push('Документы ревью, добавленные в `dev` за окно отчёта: дата коммиттера добавившего коммита, перенос в архив `legacy/reviews/` — не добавление.'); lines.push(''); } lines.push(tokens?.docs ? `Токены по ${tokens.docs} документам ревью: ${USAGE_KEYS.map((key) => `${key} ${tokens.totals[key]}`).join(' · ')}.` : `${TOKENS_NO_DATA}.`); if (tokens?.missing) { lines.push(''); lines.push(`Без данных о расходе: ${tokens.missing}.`); } } function renderCompare(lines, c) { lines.push(''); lines.push(`### До и после ${c.cutover}`); lines.push(''); lines.push(`Первый \`S8-merged\` в [${isoDay(c.before.from)}, ${isoDay(c.before.to)}) и [${isoDay(c.after.from)}, ${c.after.to ? isoDay(c.after.to) : '…'}), окно ${c.days} дн. Когорта — трек на момент S8 и объём задачи: \`+/−\` строк классов A и B (продукт, тесты, инструменты) в коммитах \`Issue: #NN\`, кроме коммитов кандидата беты или релиза и бота \`beta-derived\`; документация, документы ревью и бандл не входят (корзины ${VOLUME_BUCKETS.join(', ')}). Медианы в часах «до → после (разница)»; n < ${MIN_COHORT} на любой стороне — мало данных. Job-минуты в сравнение не входят.`); lines.push(''); if (!c.cohorts.length) { lines.push('Мало данных: в окне сравнения нет задач с коммитами.'); } else { lines.push('| Когорта | n до | n после | lead | work | review | rework | Возвратов (среднее) |'); lines.push('|---|---:|---:|---|---|---|---|---|'); const pair = (cohort, name) => `${fmt(cohort.before[name])} → ${fmt(cohort.after[name])} (${cohort.diff[name] == null ? '—' : `${cohort.diff[name] > 0 ? '+' : ''}${fmt(cohort.diff[name])}`})`; for (const cohort of c.cohorts) { if (!cohort.enough) { lines.push(`| ${cohort.key} | ${cohort.before.n} | ${cohort.after.n} | мало данных | | | | |`); continue; } lines.push(`| ${cohort.key} | ${cohort.before.n} | ${cohort.after.n} | ${['lead', 'work', 'review', 'rework', 'returns'].map((name) => pair(cohort, name)).join(' | ')} |`); } } if (c.noCommits.length) { lines.push(''); lines.push(`Без коммитов с трейлером или только с документами ревью (объёма нет, в когорты не входят): ${c.noCommits.map((n) => `#${n}`).join(', ')}.`); } } // --------------------------------------------------------------------------- // gh-обвязка: только чтение. function ghJson(args) { return JSON.parse(execFileSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })); } /** * Снимок для отчёта: только чтение GitHub API и локального git. * * - issue `state=all` с `since` самого раннего окна (неделя отчёта или окно * сравнения К7): задачи в `S8-merged` закрываются только с бетой (§2.8). * Прежняя выборка (`issues`) — закрытые в окне, как раньше; * - таймлайн — у прежней выборки, у задач с `S5-ready`…`S8-merged` или закрытых и у * задач прогонов конвейера окна; не дальше `TIMELINE_PAGE_CAP` страниц, * иначе задача помечается «таймлайн усечён»; * - jobs (К5) — для `jobRuns`, недоступные jobs — `null`; * - имена документов ревью, тексты `SHIP-REVIEW-*.md` и документов со строкой * расхода, дата добавления каждого документа в dev (#761), коммиты * `origin/dev` с `--numstat` за окно сравнения. */ export function fetchSnapshot({ repo, since, until, gh = ghJson, git = null, compare = {} }) { const sinceIso = new Date(since).toISOString(); const untilMs = at(until); const cutover = toMs(compare.cutover ?? TRACKS_CUTOVER); const days = Number(compare.days ?? COMPARE_DAYS); const earliestIso = new Date(Math.min(at(sinceIso), cutover - days * DAY_MS)).toISOString(); const all = []; for (let page = 1; page <= 10; page++) { const batch = gh(['api', `repos/${repo}/issues?state=all&since=${encodeURIComponent(earliestIso)}&per_page=100&page=${page}`]); if (!Array.isArray(batch) || !batch.length) break; all.push(...batch.filter((issue) => !issue.pull_request)); if (batch.length < 100) break; } const closedInWindow = (issue) => { const closed = at(issue.closed_at); return Number.isFinite(closed) && closed >= at(sinceIso) && closed <= untilMs; }; const issues = all.filter(closedInWindow); 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 { selected } = jobRuns(runs); const runIssues = new Set(selected.map((run) => Number(PROCESS_TITLE.exec(String(run.display_title || ''))?.[1])).filter(Number.isFinite)); // #752: задачи в S5–S7 — раздел «Черновик ТЗ» считает их по первому S5-ready. const allIssues = all.filter((issue) => closedInWindow(issue) || (issue.labels || []).some((label) => ['S5-ready', 'S6-in-progress', 'S7-code-review', 'S8-merged'].includes(label?.name ?? label)) || (issue.state === 'closed' && at(issue.closed_at) >= at(earliestIso)) || runIssues.has(Number(issue.number))); const timelines = new Map(); const timelineTruncated = new Set(); for (const issue of allIssues) { const events = []; for (let page = 1; page <= TIMELINE_PAGE_CAP; 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; if (page === TIMELINE_PAGE_CAP) timelineTruncated.add(Number(issue.number)); } timelines.set(Number(issue.number), events); } const jobsByRun = new Map(); for (const run of selected) { try { const jobs = []; for (let page = 1; page <= 3; page++) { const batch = gh(['api', `repos/${repo}/actions/runs/${run.id}/jobs?per_page=100&page=${page}`]); const rows = batch?.jobs || []; jobs.push(...rows); if (rows.length < 100) break; } jobsByRun.set(run.id, jobs); } catch { jobsByRun.set(run.id, null); // 403, истёк срок хранения: «нет данных», а не ноль } } let reviewFiles = []; const reviewDocs = []; let reviewDocAdded = null; let commits = []; if (git) { const listing = git(['ls-tree', '-r', '--name-only', 'HEAD', '--', 'docs/reviews', 'legacy/reviews']); reviewFiles = reviewDocNames(listing); const paths = String(listing).split('\n').map((path) => path.trim()).filter(Boolean); let usage = []; try { usage = git(['grep', '-l', '-e', 'hp:usage', 'HEAD', '--', 'docs/reviews', 'legacy/reviews']) .split('\n').map((line) => line.trim().replace(/^HEAD:/, '')).filter(Boolean); } catch { /* git grep без совпадений выходит с кодом 1 */ } for (const path of new Set([...paths.filter((p) => SHIP_DOC.test(p)), ...usage])) { reviewDocs.push({ path, text: git(['show', `HEAD:${path}`]) }); } // #761: окно токенов — по коммиту, добавившему документ в dev; с -M перенос // в архив (#682) — переименование, а не добавление. reviewDocAdded = reviewDocAddedAt(git(['log', '-M', '--diff-filter=AR', '--reverse', '--name-status', '--format=%x1e%cI', 'HEAD', '--', 'docs/reviews', 'legacy/reviews'])); let ref = 'origin/dev'; try { git(['rev-parse', '--verify', '-q', ref]); } catch { ref = 'HEAD'; } // Коммиты задачи бывают раньше её S8: запас в одно окно до начала сравнения. commits = readCommits(git, { ref, since: at(earliestIso) - days * DAY_MS }); } return { issues, allIssues, timelines, timelineTruncated, runs, jobsByRun, reviewFiles, reviewDocs, reviewDocAdded, commits }; } 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); const compare = { cutover: arg('compare', TRACKS_CUTOVER), days: Number(arg('compare-days', String(COMPARE_DAYS))) }; if (!repo || !Number.isFinite(at(compare.cutover)) || !(compare.days > 0)) { console.error('usage: process-metrics.mjs --repo= [--days=7] [--until=ISO] [--output=file.md] [--json=file.json] [--compare=YYYY-MM-DD] [--compare-days=28]'); 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', maxBuffer: 256 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }); const snapshot = fetchSnapshot({ repo, since, until, git, compare }); // #728: jobs передаёт только CLI — `buildReport` без них job-минут не выдумывает. const report = buildReport({ since, until, ...snapshot, compare }); 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); }