diff --git a/.github/workflows/_process-metrics.yml b/.github/workflows/_process-metrics.yml index 8ba0e103..6884d234 100644 --- a/.github/workflows/_process-metrics.yml +++ b/.github/workflows/_process-metrics.yml @@ -5,6 +5,12 @@ name: "Метрики процесса · тело (#623)" # идёт в step summary и артефакт; в issue и репозиторий ничего не пишется. # Цифры аудита 22.09 (1,57 раунда код-ревью, S7 ≈ 36 мин/раунд, 226 Validate # за неделю) были собраны руками за час — теперь они стоят один запуск. +# +# #728: эффект процесса по трекам — отрезки, возвраты с причинами, находки +# ship, job-минуты по стадиям и сравнение до/после 28.09. Прогон дороже: +# jobs до 600 прогонов и таймлайны задач окна сравнения (≈ 600–800 запросов), +# поэтому потолок 30 минут; трек инфраструктуры и объём задачи читаются из +# истории git — нужна полная история dev. on: # #623: тело вызывается тонким файлом `process-metrics.yml` из ветки по умолчанию @@ -26,14 +32,15 @@ jobs: metrics: name: "Снимок недели: issue, раунды, прогоны" runs-on: ubuntu-24.04 - timeout-minutes: 15 + timeout-minutes: 30 steps: # Код — из dev, как у reconcile: расписание читается из main, а исполняется - # версия, которую проверил CI. + # версия, которую проверил CI. Полная история (#728): коммиты задачи по + # трейлеру `Issue: #NN` дают признак инфраструктуры и объём для сравнения. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: dev - fetch-depth: 1 + fetch-depth: 0 persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: diff --git a/PROCESS.md b/PROCESS.md index ddd5f44d..c7a089f4 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -562,6 +562,8 @@ patch-id кандидата слияния: вердикт к работе за короткий (`trivial`) — 41 мин и 5 событий. Трек определялся формальными критериями, и у владельца не было метки, чтобы задать его самому. +Эффект треков меряет еженедельный отчёт `process-metrics.yml` (#728). + **Трек задаёт метка `track:ship`, `track:show` или `track:ask`. Метка владельца главнее критериев:** критерии ниже — подсказка аналитика, а не приговор. diff --git a/scripts/process-metrics.mjs b/scripts/process-metrics.mjs index fbe79f8e..c04d6ecc 100644 --- a/scripts/process-metrics.mjs +++ b/scripts/process-metrics.mjs @@ -10,11 +10,27 @@ // stdout/файл; куда его класть, решает workflow (`process-metrics.yml`: // step summary + artifact, опционально комментарий в issue-журнал). // +// +// #728: эффект процесса по трекам — трек на момент события, отрезки времени +// (работа, ожидание, переделка), возвраты с причинами, находки пакетного ревью +// ship, job-минуты по стадиям, токены и сравнение сопоставимых задач до и после +// перехода на треки. Определения — в ТЗ #728 и в заголовках разделов отчёта. +// Отчёт только читает: признаки конвейера импортируются, а не копируются, где +// у конвейера есть константа; где её нет (`validate-red`/`conflict`), копию +// текста держит контрактный тест на шаблонах `_process.yml`. +// // 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 { verdictDeclaration } from './review-doc-guard.mjs'; +import { parseAnchorBlock } from './ship-review.mjs'; +import { PIPELINE_EVENTS } from './wait-verdict.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)(?: ·|$)/; @@ -160,8 +176,577 @@ export function jobMinutes(jobsByRun = new Map()) { 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', '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 = //; +/** + * Машинная строка расхода модели в документе ревью — её запишет конвейер + * (issue F, К6 #728): ``. + * Формат предварительный: пока строки нет нигде, отчёт печатает «нет данных». + */ +export const USAGE_LINE_RE = //g; + +/** Признак конвейера по `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')]; + +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`: семейство узнано, причина не угадывается. + */ +export function returnSignal(body, { stage = 'code', number } = {}) { + const text = String(body ?? ''); + const route = ROUTE_RE.exec(text); + if (route) return route[1]; + 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 }; +} + +/** К6. Расход модели по машинным строкам документов ревью; ни одной — `null` («нет данных»). */ +export function tokenUsage(reviewDocs = []) { + const totals = {}; + let docs = 0; + for (const doc of reviewDocs || []) { + let found = false; + for (const match of String(doc?.text ?? '').matchAll(USAGE_LINE_RE)) { + found = true; + for (const [, key, value] of match[1].matchAll(/([a-z_]+)=(\d+)/g)) totals[key] = (totals[key] || 0) + Number(value); + } + if (found) docs += 1; + } + return docs ? { docs, totals } : null; +} + +/** `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: не класс D (`classify`) и не `docs/reviews/**`. */ +const countsToVolume = (path) => classify(path) !== 'D' && !String(path).startsWith('docs/reviews/'); + +/** + * Изменения задачи по коммитам с трейлером `Issue: #NN`, кроме коммитов + * `Release:` (они несут трейлеры всех задач беты, бандлы и версию манифеста): + * объём К7 (`+/−` без класса D и `docs/reviews/**`) и файлы для признака + * инфраструктуры К1. + */ +export function issueChanges(commits = []) { + const byIssue = new Map(); + for (const commit of commits || []) { + if (/^Release:/m.test(String(commit.body || ''))) 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 || []) { + 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: ≤ 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 base = { + number, title: String(issue.title || ''), stateReason: issue.state_reason || null, s8At: s8.at, + infra, volume: change ? change.lines : null, bucket: change ? volumeBucket(change.lines) : null, + }; + 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), + }; +} + +/** Стадия конвейера по имени 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 }) { +export function buildReport({ + since, until, issues = [], timelines = new Map(), reviewFiles = [], runs = [], jobsByRun = null, + // #728: задачи для разделов по трекам (по умолчанию — те же `issues`), усечённые + // таймлайны, тексты документов ревью, коммиты dev и окно сравнения. + allIssues = null, timelineTruncated = new Set(), reviewDocs = [], commits = [], compare = {}, +}) { const perIssue = issues.map((issue) => issueMetrics(issue, timelines.get(Number(issue.number)) || [])); const rounds = reviewRounds(reviewFiles); const completed = perIssue.filter((issue) => issue.stateReason === 'completed'); @@ -190,6 +775,36 @@ export function buildReport({ since, until, issues = [], timelines = new Map(), runs: runMetrics(runs), pipeline: pipelineMetrics(runs), jobs: jobsByRun ? jobMinutes(jobsByRun) : null, + ...trackReport({ since, until, issues: allIssues || issues, timelines, timelineTruncated, reviewDocs, runs, jobsByRun, commits, compare }), + }; +} + +/** #728: разделы по трекам, job-минуты по стадиям, токены и сравнение до/после. */ +function trackReport({ since, until, issues, timelines, timelineTruncated, reviewDocs, 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 }), + stages: jobsByRun ? stageMinutes({ runs, jobsByRun, trackOf }) : null, + tokens: tokenUsage(reviewDocs), + compare: compareCohorts(rows, { cutover: compare.cutover ?? TRACKS_CUTOVER, days: compare.days ?? COMPARE_DAYS, until }), }; } @@ -236,9 +851,139 @@ export function renderMarkdown(report) { } lines.push(''); lines.push(''); + // #728: новые разделы — после прежних; прежние строки не меняются. + if (report.tracks) renderTracks(lines, report.tracks); + 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 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(''); + lines.push(tokens + ? `Токены по ${tokens.docs} документам ревью: ${Object.entries(tokens.totals).map(([key, value]) => `${key} ${value}`).join(' · ')}.` + : `${TOKENS_NO_DATA}.`); +} + +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 и объём задачи: \`+/−\` строк коммитов \`Issue: #NN\` без \`Release:\`, класса D и \`docs/reviews/**\` (корзины ${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-обвязка: только чтение. @@ -246,31 +991,37 @@ 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 }) { +/** + * Снимок для отчёта: только чтение GitHub API и локального git. + * + * - issue `state=all` с `since` самого раннего окна (неделя отчёта или окно + * сравнения К7): задачи в `S8-merged` закрываются только с бетой (§2.8). + * Прежняя выборка (`issues`) — закрытые в окне, как раньше; + * - таймлайн — у прежней выборки, у задач с `S8-merged` или закрытых и у + * задач прогонов конвейера окна; не дальше `TIMELINE_PAGE_CAP` страниц, + * иначе задача помечается «таймлайн усечён»; + * - jobs (К5) — для `jobRuns`, недоступные jobs — `null`; + * - имена документов ревью, тексты `SHIP-REVIEW-*.md` и документов со строкой + * расхода, коммиты `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 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}`]); + 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; - 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); - } + all.push(...batch.filter((issue) => !issue.pull_request)); 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 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}`]); @@ -278,25 +1029,78 @@ export function fetchSnapshot({ repo, since, until, gh = ghJson, git = null }) { runs.push(...rows); if (rows.length < 100) break; } - const reviewFiles = git - ? reviewDocNames(git(['ls-tree', '-r', '--name-only', 'HEAD', '--', 'docs/reviews', 'legacy/reviews'])) - : []; - return { issues, timelines, runs, reviewFiles }; + const { selected } = jobRuns(runs); + const runIssues = new Set(selected.map((run) => Number(PROCESS_TITLE.exec(String(run.display_title || ''))?.[1])).filter(Number.isFinite)); + const allIssues = all.filter((issue) => closedInWindow(issue) + || (issue.labels || []).some((label) => (label?.name ?? label) === 'S8-merged') + || (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 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}`]) }); + } + 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, 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); - if (!repo) { - console.error('usage: process-metrics.mjs --repo= [--days=7] [--until=ISO] [--output=file.md] [--json=file.json]'); + 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' }); - const snapshot = fetchSnapshot({ repo, since, until, git }); - const report = buildReport({ since, until, ...snapshot }); + 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'); } diff --git a/test/process-metrics.test.mjs b/test/process-metrics.test.mjs index 10083494..aa6057ff 100644 --- a/test/process-metrics.test.mjs +++ b/test/process-metrics.test.mjs @@ -1,10 +1,20 @@ // #637: еженедельный замер процесса — чистые функции над снимками GitHub. import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; import { buildReport, issueMetrics, jobMinutes, pipelineMetrics, renderMarkdown, reviewDocNames, reviewRounds, runMetrics, + NOT_RUN_CONFLICT_RE, NOT_RUN_VALIDATE_RE, TIMELINE_PAGE_CAP, TOKENS_NO_DATA, TRACKS_CUTOVER, + compareCohorts, fetchSnapshot, isInfra, issueChanges, issueSegments, issueTrackMetrics, jobRuns, jobStage, + readCommits, returnSignal, shipFindings, stageMinutes, tokenUsage, trackAt, trackPath, trackSection, volumeBucket, } from '../scripts/process-metrics.mjs'; +import { labelTrack } from '../scripts/process-track.mjs'; +import { commentFor } from '../scripts/merge-candidate.mjs'; +import { anchorBlock } from '../scripts/ship-review.mjs'; +import { PIPELINE_EVENTS } from '../scripts/wait-verdict.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) }); @@ -155,3 +165,401 @@ test('#682 reviewDocNames: живой каталог и архив legacy/review // Перенос r1 в архив не уменьшает число раундов задачи. assert.equal(reviewRounds(names).get('CODE:600'), 2); }); + +// --------------------------------------------------------------------------- +// #728: эффект процесса по трекам. + +const unlabeled = (name, h) => ({ event: 'unlabeled', label: { name }, created_at: T(h) }); +const commented = (body, h) => ({ event: 'commented', body, created_at: T(h) }); +const labeledAt = (name, iso) => ({ event: 'labeled', label: { name }, created_at: iso }); +const HOUR = 3_600_000; + +test('#728 trackAt: трек на момент события и путь трека, прежние метки и инфраструктура (AC1)', () => { + const events = [ + labeled('S1-new', 0), labeled('track:ship', 0), labeled('S5-ready', 1), labeled('S6-in-progress', 2), + unlabeled('track:ship', 5), labeled('track:show', 5), labeled('S7-code-review', 6), labeled('S8-merged', 9), + unlabeled('track:show', 10), labeled('track:ask', 10), + ]; + assert.equal(trackAt(events, T(4)), 'ship', 'до смены'); + assert.equal(trackAt(events, T(5)), 'ship', 'в сам момент смены — метки строго до него'); + assert.equal(trackAt(events, T(6)), 'show', 'после смены'); + assert.deepEqual(trackPath(events, { from: T(0), to: T(9) }), ['ship', 'show']); + const row = issueTrackMetrics({ number: 701 }, events); + assert.equal(row.track, 'show', 'трек задачи — на момент S8, смена после слияния не в счёт'); + assert.deepEqual(row.path, ['ship', 'show']); + + // Прежние метки до 28.09 — по §5.1. + const legacy = [labeledAt('S1-new', '2026-09-20T10:00:00Z'), labeledAt('small', '2026-09-20T10:00:00Z')]; + assert.equal(trackAt(legacy, '2026-09-21T00:00:00Z'), 'show', 'small → show'); + const bare = [labeledAt('S1-new', '2026-09-20T10:00:00Z')]; + assert.equal(trackAt(bare, '2026-09-21T00:00:00Z'), 'ask', 'без метки, продукт → ask'); + assert.equal(trackAt(bare, '2026-09-21T00:00:00Z', { infra: true }), 'show', 'без метки, инфраструктура → show'); + // Две трековые метки — как у process-track.mjs: строжайшая. + const both = [labeled('track:ship', 0), labeled('track:ask', 0)]; + assert.equal(trackAt(both, T(1)), labelTrack({ labels: ['track:ship', 'track:ask'] })); + assert.equal(trackAt(both, T(1)), 'ask'); + + // infra — ни одного файла класса A в коммитах задачи; Release:-коммит не в счёт. + const commits = [ + { sha: 'a'.repeat(40), body: 'x\n\nIssue: #801\n', issues: [801], files: [{ added: 5, deleted: 0, path: 'scripts/x.mjs' }] }, + { sha: 'b'.repeat(40), body: 'x\n\nIssue: #802\n', issues: [802], files: [{ added: 5, deleted: 0, path: 'src/x.ts' }] }, + { sha: 'c'.repeat(40), body: 'rel\n\nIssue: #801\nRelease: v1.0.0-beta.1\n', issues: [801], files: [{ added: 1, deleted: 1, path: 'custom_components/houseplan/manifest.json' }] }, + ]; + const timelines = new Map([[801, [labeled('S6-in-progress', 0), labeled('S8-merged', 2)]], [802, [labeled('S6-in-progress', 0), labeled('S8-merged', 2)]]]); + const report = buildReport({ since: T(0), until: T(48), issues: [{ number: 801 }, { number: 802 }], timelines, commits }); + const byNumber = new Map(report.tracks.rows.map((r) => [r.number, r])); + assert.equal(byNumber.get(801).track, 'show', 'инфраструктура без метки → show'); + assert.equal(byNumber.get(802).track, 'ask', 'продукт без метки → ask'); +}); + +test('#728 issueSegments: отрезки по часам, blocked вычитается, сумма равна lead (AC2)', () => { + const events = [ + labeled('S1-new', 0), labeled('S5-ready', 1), labeled('S6-in-progress', 2), + labeled('blocked', 3), unlabeled('blocked', 4), + labeled('S7-code-review', 5), labeled('S6-in-progress', 6), labeled('S7-code-review', 8), labeled('S8-merged', 9), + ]; + const seg = issueSegments(events); + const inHours = Object.fromEntries(Object.entries(seg.segments).map(([k, v]) => [k, v / HOUR])); + assert.deepEqual(inHours, { queue: 2, spec: 0, work: 2, review: 2, rework: 2, blocked: 1 }); + assert.equal(seg.leadMs, 9 * HOUR); + assert.equal(Object.values(seg.segments).reduce((a, b) => a + b, 0), seg.leadMs, 'сумма отрезков = lead'); + assert.equal(seg.returns.length, 1); + + // Повторная постановка S7 конвейером (#636, #706) — не возврат и не новый отрезок. + const repeated = issueSegments([ + ...events.slice(0, 6), labeled('S7-code-review', 5.5), + events[6], events[7], unlabeled('S7-code-review', 8.5), labeled('S7-code-review', 8.5), events[8], + ]); + assert.deepEqual(repeated.segments, seg.segments); + assert.equal(repeated.returns.length, 1); + // …и не сдвигает начало окна причины: вердикт до повторной постановки остаётся причиной. + const verdictBefore = issueTrackMetrics({ number: 701 }, [ + ...events.slice(0, 6), commented('Вердикт: жёлтый · Документ: docs/reviews/CODE-REVIEW-701-r1.md', 5.2), + labeled('S7-code-review', 5.5), ...events.slice(6), + ]); + assert.deepEqual(verdictBefore.returns.map((r) => r.reason), ['verdict-yellow']); + + // Ревью ТЗ: S3 до первого возврата — spec, после S4 → S3 — rework. + const spec = issueSegments([ + labeled('S3-spec', 0), labeled('S4-spec-review', 2), labeled('S3-spec', 3), labeled('S4-spec-review', 4), + labeled('S5-ready', 5), labeled('S6-in-progress', 6), labeled('S7-code-review', 7), labeled('S8-merged', 8), + ]); + assert.deepEqual(Object.fromEntries(Object.entries(spec.segments).map(([k, v]) => [k, v / HOUR])), + { queue: 1, spec: 2, work: 1, review: 3, rework: 1, blocked: 0 }); + assert.deepEqual(spec.returns.map((r) => r.stage), ['spec']); + assert.equal(issueSegments([labeled('S6-in-progress', 0)]), null, 'без S8 — не в выборке'); +}); + +const NUM = 701; +const VERDICT_YELLOW = `Вердикт: жёлтый · заход r1 · High: 0 · Medium: 2 · Документ: docs/reviews/CODE-REVIEW-${NUM}-r1.md`; +const VERDICT_RED = `**Вердикт: красный** · заход r2 · High: 1 · Документ: docs/reviews/CODE-REVIEW-${NUM}-r2.md`; + +/** Шаблоны «Ревью не запускалось» из `_process.yml`: снять экранирование и подставить `$kind`. */ +function notRunTemplates() { + const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8'); + const templates = workflow.split('\n').map((line) => line.trim()).filter((line) => line.startsWith('**Ревью не запускалось:**')); + const step = workflow.slice(workflow.indexOf('- name: Validate красный — вернуть автору без ревью')); + const kinds = ['Validate', 'Validate с мутантами']; + for (const kind of kinds) assert.ok(step.slice(0, 1500).includes(`kind="${kind}"`), `шаг Validate красный задаёт kind="${kind}"`); + return templates.map((line) => kinds.map((kind) => line.replaceAll('\\`', '`').replaceAll('$kind', kind) + .replaceAll('$BRANCH', 'issue/701-white-tile').replaceAll('$short', '1a2b3c4').replaceAll('$RESULT', 'failed'))); +} + +test('#728 returnSignal: причины возврата на текстах конвейера (AC3)', () => { + const sig = (body) => returnSignal(body, { stage: 'code', number: NUM }); + assert.equal(sig(VERDICT_YELLOW), 'verdict-yellow'); + assert.equal(sig(VERDICT_RED), 'verdict-red'); + assert.equal(sig(VERDICT_YELLOW.replace(`CODE-REVIEW-${NUM}`, 'CODE-REVIEW-702')), null, 'вердикт о чужом документе — не причина'); + assert.equal(sig(`Вердикт: зелёный · заход r1 · Документ: docs/reviews/CODE-REVIEW-${NUM}-r1.md`), null, 'зелёный — не причина возврата'); + assert.equal(sig(commentFor('reject-stale', { material: 'a'.repeat(40), actual: 'b'.repeat(40) })), 'merge'); + assert.equal(sig(commentFor('conflict', { branch: 'issue/701-x' })), 'merge'); + assert.ok(PIPELINE_EVENTS.find((e) => e.kind === 'stale').re.test(commentFor('reject-stale', {})), 'текст под константу конвейера'); + assert.equal(sig('**Ревью не запускалось:** что-то третье, о чём отчёт не знает.'), 'unknown'); + const refused = commentFor('push-refused-workflow', { + stage: 'rebase', candidate: 'c'.repeat(40), branch: 'issue/701-x', refusal: { files: ['.github/workflows/x.yml'] }, + }); + assert.match(refused, /^\*\*Ревью не запускалось: кандидат меняет workflow-файл/); + assert.equal(sig(refused), null, 'отказ push по праву на workflow (#705) — без признака, возврат уйдёт в unknown'); + assert.equal(sig('Текст.\n\n'), 'reclassify'); + assert.equal(sig('Текст.\n\n'), 'owner-question'); + assert.equal(sig('Пока шло ревью, `dev` продвинулся на 2 коммит(ов).'), null); + assert.equal(NOT_RUN_VALIDATE_RE.flags.includes('m') && NOT_RUN_CONFLICT_RE.flags.includes('m'), true); +}); + +test('#728 контракт: шаблоны «Ревью не запускалось» _process.yml дают conflict и validate-red (AC3)', () => { + const templates = notRunTemplates(); + assert.equal(templates.length, 2, 'шаблонов «Ревью не запускалось:» в _process.yml ровно два'); + const reasons = templates.map((variants) => [...new Set(variants.map((text) => returnSignal(text, { stage: 'code', number: NUM })))]); + assert.deepEqual(reasons.map((r) => r.join(',')).sort(), ['conflict', 'validate-red'], + 'один шаблон — conflict, другой — validate-red при обоих $kind'); + for (const variants of templates) { + for (const text of variants) assert.ok(PIPELINE_EVENTS.find((e) => e.kind === 'conflict').re.test(text), 'общий префикс — под константой PIPELINE_EVENTS'); + } +}); + +test('#728 returnReason: последний комментарий с признаком, возврат без комментария — unknown, трек своего момента (AC3)', () => { + const issue = { number: NUM }; + const cycle = (h, comment) => [ + labeled('S7-code-review', h), ...(comment ? [commented(comment, h + 0.5)] : []), labeled('S6-in-progress', h + 0.5), + ]; + const templates = notRunTemplates(); + const conflict = templates.find((variants) => variants[0].includes(' ветка ')); + const validate = templates.find((variants) => variants !== conflict); + const events = [ + labeled('S1-new', 0), labeled('track:ship', 0), labeled('S6-in-progress', 1), + ...cycle(2, VERDICT_YELLOW), + ...cycle(3, VERDICT_RED), + ...cycle(4, validate[0]), + ...cycle(5, validate[1]), + unlabeled('track:ship', 5.8), labeled('track:show', 5.8), + ...cycle(6, conflict[0]), + ...cycle(7, commentFor('reject-stale', {})), + // Комментарий без признака после вердикта не перекрывает причину. + labeled('S7-code-review', 8), commented(VERDICT_YELLOW, 8.2), commented('Пока шло ревью, `dev` продвинулся на 1 коммит(ов).', 8.3), labeled('S6-in-progress', 8.5), + ...cycle(9, null), + ...cycle(10, VERDICT_YELLOW.replace(`CODE-REVIEW-${NUM}`, 'CODE-REVIEW-702')), + ...cycle(11, '**Ревью не запускалось:** иное продолжение.'), + ...cycle(12, commentFor('push-refused-workflow', { stage: 'rebase', candidate: 'c'.repeat(40), branch: 'b' })), + labeled('S7-code-review', 13), commented('Ревью show: решать есть что.\n', 13.5), + labeled('blocked', 13.5), labeled('S6-in-progress', 13.5), unlabeled('blocked', 13.8), + labeled('S7-code-review', 14), commented('Трек повышен.\n', 14.5), + labeled('S3-spec', 14.5), unlabeled('track:show', 14.5), labeled('track:ask', 14.5), + labeled('S4-spec-review', 15), commented(`Вердикт: красный · Документ: docs/reviews/SPEC-REVIEW-${NUM}-r1.md`, 15.5), labeled('S3-spec', 15.5), + labeled('S4-spec-review', 16), labeled('S5-ready', 16.5), labeled('S6-in-progress', 17), labeled('S7-code-review', 18), labeled('S8-merged', 19), + ]; + const row = issueTrackMetrics(issue, events); + assert.deepEqual(row.returns.map((r) => r.reason), [ + 'verdict-yellow', 'verdict-red', 'validate-red', 'validate-red', 'conflict', 'merge', 'verdict-yellow', + 'unknown', 'unknown', 'unknown', 'unknown', 'owner-question', 'reclassify', 'verdict-red', + ]); + assert.deepEqual(row.returns.map((r) => r.track), [ + 'ship', 'ship', 'ship', 'ship', 'show', 'show', 'show', 'show', 'show', 'show', 'show', 'show', 'show', 'ask', + ], 'возврат — по треку своего момента; reclassify — на треке, где шло ревью'); + assert.deepEqual(row.path, ['ship', 'show', 'ask']); + assert.equal(row.track, 'ask'); + const section = trackSection([row]); + const ship = section.events.find((e) => e.track === 'ship'); + assert.deepEqual([ship.returns, ship.reasons['verdict-yellow'], ship.reasons['validate-red']], [4, 1, 2]); + assert.equal(section.events.find((e) => e.track === 'show').reasons.unknown, 4); + // Раунды — объявления вердикта этапа, по треку и блокирующие/зелёные. + assert.deepEqual([ship.rounds.code.blocking, ship.rounds.code.green], [2, 0]); + assert.deepEqual(section.events.find((e) => e.track === 'ask').rounds.spec, { blocking: 1, green: 0 }); +}); + +test('#728 shipFindings: находки пакетного ревью ship по машинному блоку, архив тоже читается (AC4)', () => { + const doc = (tag, issues, high, medium, low) => anchorBlock({ tag, candidate: 'c'.repeat(40), issues, high, medium, low }); + const reviewDocs = [ + { path: 'docs/reviews/SHIP-REVIEW-v1.79.0-beta.2.md', text: `# Пакетное ревью\n\n${doc('v1.79.0-beta.2', [701, 702], 0, 1, 2)}` }, + { path: 'legacy/reviews/v1.78.0/SHIP-REVIEW-v1.78.0-beta.9.md', text: doc('v1.78.0-beta.9', [703], 1, 0, 0) }, + { path: 'docs/reviews/CODE-REVIEW-701-r1.md', text: doc('v1.79.0-beta.2', [701], 9, 9, 9) }, + ]; + const { byIssue } = shipFindings(reviewDocs); + assert.deepEqual(byIssue.get(701), { high: 0, medium: 1, low: 2, docs: ['docs/reviews/SHIP-REVIEW-v1.79.0-beta.2.md'] }); + assert.deepEqual([byIssue.get(702).medium, byIssue.get(702).low], [1, 2]); + assert.equal(byIssue.get(703).high, 1, 'документ из legacy/reviews/ читается'); + assert.equal(byIssue.has(704), false, 'задачи нет в блоке — находок нет'); + + const shipped = (n) => [labeled('S1-new', 0), labeled('track:ship', 0), labeled('S6-in-progress', 1), labeled('S7-code-review', 2), labeled('S8-merged', 3)]; + const timelines = new Map([701, 702, 704].map((n) => [n, shipped(n)])); + const report = buildReport({ since: T(0), until: T(48), issues: [701, 702, 704].map((number) => ({ number })), timelines, reviewDocs }); + const rows = new Map(report.tracks.rows.map((r) => [r.number, r])); + assert.deepEqual([rows.get(701).ship.medium, rows.get(701).ship.low], [1, 2]); + assert.deepEqual(rows.get(704).ship, { high: 0, medium: 0, low: 0, docs: [] }); + const ship = report.tracks.byTrack.find((r) => r.track === 'ship').ship; + assert.deepEqual(ship, { issues: 3, covered: 2, docs: 1, high: 0, medium: 1, low: 2 }, 'документ на две задачи — один раз'); + assert.match(renderMarkdown(report), /\| ship \| 3 \|[^\n]*покрыто 2 из 3, документов 1: High 0 · Medium 1 · Low 2 \|/); +}); + +const jobsRun = (id, over) => ({ id, name: 'Ревью-конвейер', event: 'issues', conclusion: 'success', run_started_at: T(10), updated_at: T(11), ...over }); +const job = (name, startH, endH) => ({ name, started_at: T(startH), completed_at: T(endH) }); + +test('#728 stageMinutes: job-минуты по стадиям и треку, Validate по событию, усечение, нет данных (AC5)', () => { + const runs = [ + jobsRun(1, { display_title: 'process #701 · S7-code-review · x' }), + jobsRun(2, { display_title: 'process #701 · bug · x', conclusion: 'skipped' }), + jobsRun(3, { name: 'Проверка (CI)', display_title: 'feat: x', event: 'push' }), + jobsRun(4, { name: 'Проверка (CI)', display_title: 'feat: x', event: 'workflow_dispatch' }), + ]; + const jobsByRun = new Map([ + [1, [ + job('dev / Страж: ребейз на dev и предпосылки ревью', 10, 10.1), + job('dev / Ревью: материал и deterministic gates', 10.1, 10.3), + job('dev / Ревью: работа модели', 10.3, 10.8), + job('dev / Ревью: публикация и интеграция', 10.8, 10.9), + ]], + [3, [job('Фронтенд: типы', 10, 10.25), job('Мутанты по диффу (1/6): x', 10, 10.5)]], + [4, [job('Фронтенд: типы', 10, 11)]], + ]); + assert.deepEqual(jobRuns(runs).selected.map((r) => r.id), [1, 3, 4], 'skipped без jobs'); + const timelines = new Map([[701, [labeled('S1-new', 0), labeled('track:ship', 0), labeled('S6-in-progress', 1), labeled('S8-merged', 20)]]]); + const report = buildReport({ since: T(0), until: T(48), issues: [{ number: 701 }], timelines, runs, jobsByRun }); + assert.deepEqual(report.stages.process.stages, { guard: 6, prepare: 12, model: 30, integrate: 6, other: 0 }); + assert.deepEqual(report.stages.process.byTrack, { ship: { guard: 6, prepare: 12, model: 30, integrate: 6, other: 0 } }); + assert.deepEqual(report.stages.validate, { runs: 2, minutes: 105, byEvent: { push: 45, workflow_dispatch: 60 } }); + assert.equal(report.stages.truncated, null); + const md = renderMarkdown(report); + assert.match(md, /### Job-минуты по стадиям[\s\S]*\| model \| 30 \| 30 \|/); + assert.match(md, /Validate: \*\*105\*\* мин за 2 прогонов \(push 45, workflow_dispatch 60\)/); + assert.match(md, /Job-минуты: \*\*159\*\*, из них «Мутанты» 30/, 'прежняя строка снова печатается'); + + // 601-й прогон — усечено. + const many = Array.from({ length: 601 }, (_, k) => jobsRun(100 + k, { name: 'Проверка (CI)', display_title: 'x', event: 'push' })); + assert.equal(jobRuns(many).selected.length, 600); + const capped = stageMinutes({ runs: many, jobsByRun: new Map(many.map((r) => [r.id, []])) }); + assert.deepEqual(capped.truncated, { fetched: 600, total: 601 }); + assert.match(renderMarkdown(buildReport({ since: T(0), until: T(48), runs: many, jobsByRun: new Map() })), /усечено: 600 из 601 прогонов/); + + // Jobs недоступны (403, истёк срок) — «нет данных», а не ноль. + const gone = buildReport({ since: T(0), until: T(48), issues: [{ number: 701 }], timelines, runs, jobsByRun: new Map([[1, null], [3, null], [4, null]]) }); + assert.equal(gone.stages.process, null); + assert.equal(gone.stages.validate, null); + assert.equal(gone.stages.unavailable, 3); + const goneMd = renderMarkdown(gone); + assert.match(goneMd, /Конвейер: нет данных/); + assert.match(goneMd, /Validate: нет данных/); + assert.doesNotMatch(goneMd.slice(goneMd.indexOf('### Job-минуты по стадиям')), /\| guard \| 0/); +}); + +test('#728 контракт: имена job _process.yml дают четыре стадии конвейера (AC5)', () => { + const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8'); + const names = [...workflow.matchAll(/^ {4}name: "([^"]+)"$/gm)].map((m) => m[1]); + assert.deepEqual(names.map((name) => jobStage(`dev / ${name}`)), ['guard', 'prepare', 'model', 'integrate']); +}); + +test('#728 tokenUsage: без строки расхода — «нет данных», чисел токенов нет (AC6)', () => { + const report = buildReport({ since: T(0), until: T(48), reviewDocs: [{ path: 'docs/reviews/CODE-REVIEW-701-r1.md', text: '# Ревью\n\nВердикт: зелёный' }] }); + assert.equal(report.tokens, null); + const md = renderMarkdown(report); + assert.ok(md.includes(TOKENS_NO_DATA)); + assert.equal(TOKENS_NO_DATA, 'Токены: нет данных (конвейер не записывает расход модели)'); + const section = md.slice(md.indexOf('### Токены'), md.indexOf('###', md.indexOf('### Токены') + 3)); + assert.doesNotMatch(section, /\d/, 'ни оценок, ни пересчётов из минут'); + const recorded = tokenUsage([{ path: 'docs/reviews/CODE-REVIEW-701-r1.md', text: '' }]); + assert.deepEqual(recorded, { docs: 1, totals: { input_tokens: 1200, output_tokens: 300 } }, 'когда строка появится (issue F), она читается'); +}); + +test('#728 сравнение: объём из git без Release:, dist/** и docs/reviews/**, корзины и «мало данных» (AC7)', (t) => { + const dir = mkdtempSync(join(tmpdir(), 'hp-728-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const git = (...args) => { + const r = spawnSync('git', args, { cwd: dir, encoding: 'utf8' }); + assert.equal(r.status, 0, r.stderr); + return r.stdout; + }; + git('init', '-q', '-b', 'dev'); + git('config', 'user.email', 't@t'); git('config', 'user.name', 't'); + git('config', 'core.hooksPath', '/dev/null'); + const write = (path, lines) => { mkdirSync(join(dir, dirname(path)), { recursive: true }); writeFileSync(join(dir, path), `${Array.from({ length: lines }, (_, k) => `l${k}`).join('\n')}\n`); }; + write('src/a.ts', 10); write('dist/houseplan-card.js', 500); write('docs/reviews/CODE-REVIEW-701-r1.md', 40); + git('add', '.'); git('commit', '-q', '-m', 'feat: a (#701)\n\nIssue: #701\nUser-Visible: no'); + write('src/b.ts', 300); + git('add', '.'); git('commit', '-q', '-m', 'Release v1.0.0-beta.1 candidate\n\nIssue: #701\nRelease: v1.0.0-beta.1'); + write('scripts/x.mjs', 25); + git('add', '.'); git('commit', '-q', '-m', 'test: x (#702)\n\nIssue: #702'); + const commits = readCommits((args) => git(...args), { ref: 'HEAD', since: '2000-01-01' }); + assert.equal(commits.length, 3); + const changes = issueChanges(commits); + assert.equal(changes.get(701).lines, 10, 'без Release:-коммита, без dist/** и без docs/reviews/**'); + assert.equal(changes.get(702).lines, 25); + assert.equal(isInfra(changes.get(701)), false); + assert.equal(isInfra(changes.get(702)), true); + + assert.deepEqual([0, 30, 31, 200, 201, 1000, 1001].map(volumeBucket), ['≤30', '≤30', '31–200', '31–200', '201–1000', '201–1000', '>1000']); + assert.equal(volumeBucket(null), null); + + const row = (number, iso, track, volume, leadH, returns = 0) => ({ + number, s8At: Date.parse(iso), track, volume, bucket: volumeBucket(volume), truncated: false, leadMs: leadH * HOUR, + segments: { queue: 0, spec: 0, work: leadH * HOUR / 2, review: leadH * HOUR / 2, rework: 0, blocked: 0 }, + returns: Array.from({ length: returns }, () => ({ reason: 'verdict-yellow' })), + }); + const rows = [ + row(1, '2026-09-01T00:00:00Z', 'show', 10, 10, 1), row(2, '2026-09-10T00:00:00Z', 'show', 20, 12, 1), row(3, '2026-09-27T23:00:00Z', 'show', 30, 14, 0), + row(4, '2026-09-28T00:00:00Z', 'show', 5, 4), row(5, '2026-09-29T00:00:00Z', 'show', 6, 6), row(6, '2026-10-01T00:00:00Z', 'show', 7, 8), + row(7, '2026-09-05T00:00:00Z', 'ask', 150, 40), row(8, '2026-09-06T00:00:00Z', 'ask', 160, 50), + row(9, '2026-09-30T00:00:00Z', 'ask', 170, 20), row(10, '2026-10-02T00:00:00Z', 'ask', 180, 21), row(11, '2026-10-03T00:00:00Z', 'ask', 190, 22), + row(12, '2026-08-30T23:59:00Z', 'show', 10, 99), row(13, '2026-10-04T00:00:00Z', 'show', 10, 99), + { ...row(14, '2026-09-20T00:00:00Z', 'show', null, 1), bucket: null }, + ]; + const compare = compareCohorts(rows, { cutover: '2026-09-28', days: 28, until: '2026-10-04T00:00:00Z' }); + const show = compare.cohorts.find((c) => c.key === 'show · ≤30'); + assert.deepEqual([show.before.n, show.after.n, show.enough], [3, 3, true], '#12 до окна и #13 в until не входят'); + assert.deepEqual([show.before.lead, show.after.lead, show.diff.lead], [12, 6, -6]); + assert.deepEqual([show.before.returns, show.after.returns], [0.67, 0]); + const ask = compare.cohorts.find((c) => c.key === 'ask · 31–200'); + assert.deepEqual([ask.before.n, ask.after.n, ask.enough, ask.diff], [2, 3, false, null]); + assert.deepEqual(compare.noCommits, [14]); + const md = renderMarkdown({ ...buildReport({ since: T(0), until: T(1) }), compare }); + assert.match(md, /\| show · ≤30 \| 3 \| 3 \| 12 → 6 \(-6\) \|/); + assert.match(md, /\| ask · 31–200 \| 2 \| 3 \| мало данных \|/); + assert.match(md, /Без коммитов с трейлером[^\n]*#14/); +}); + +test('#728 renderMarkdown: новые разделы после прежних, JSON несёт те же поля, buildReport без jobs (AC8)', () => { + const report = buildReport({ since: T(0), until: T(48), issues: [{ number: 701, closed_at: T(30), state_reason: 'completed' }], + timelines: new Map([[701, [labeled('S1-new', 0), labeled('S7-code-review', 2), labeled('S8-merged', 3)]]]) }); + assert.equal(report.jobs, null); + assert.equal(report.stages, null, 'jobs передаёт только CLI'); + const md = renderMarkdown(report); + const order = ['', '### По трекам', '### Job-минуты по стадиям', '### Токены', `### До и после ${TRACKS_CUTOVER}`].map((s) => md.indexOf(s)); + assert.ok(order.every((i) => i > 0) && order.every((i, k) => k === 0 || i > order[k - 1]), `порядок разделов: ${order}`); + assert.match(md, /Нет данных: jobs прогонов не запрашивались/); + const json = JSON.parse(JSON.stringify(report)); + for (const key of ['tracks', 'stages', 'tokens', 'compare']) assert.ok(key in json, key); + assert.equal(json.tracks.rows[0].track, 'ask'); +}); + +test('#728 workflow: полная история и потолок 30 минут', () => { + const wf = readFileSync(new URL('../.github/workflows/_process-metrics.yml', import.meta.url), 'utf8'); + const metrics = wf.slice(wf.indexOf('\n metrics:\n')); + assert.ok(metrics.length > 1, 'job metrics'); + const head = metrics.slice(0, metrics.indexOf('\n steps:')); + assert.match(head, /^ {4}timeout-minutes: 30$/m); + assert.match(metrics, /- uses: actions\/checkout@[0-9a-f]{40}[^\n]*\n {8}with:\n(?: {10}[^\n]*\n)*? {10}fetch-depth: 0\n/); + assert.equal([...wf.matchAll(/fetch-depth:/g)].length, 1, 'другой строки fetch-depth: в файле нет'); +}); + +test('#728 fetchSnapshot: state=all, таймлайн до 10 страниц, jobs недоступны — null', () => { + const calls = []; + const full = Array.from({ length: 100 }, () => labeled('bug', 0)); + const gh = (args) => { + const path = args[1]; + calls.push(path); + if (path.startsWith('repos/o/r/issues?')) { + return path.includes('page=1') ? [ + { number: 701, state: 'open', labels: [{ name: 'S8-merged' }] }, + { number: 702, state: 'closed', closed_at: T(30), state_reason: 'completed', labels: [] }, + { number: 703, state: 'open', labels: [{ name: 'S6-in-progress' }] }, + { number: 704, pull_request: {}, state: 'open', labels: [] }, + ] : []; + } + if (path.startsWith('repos/o/r/actions/runs?')) { + return { workflow_runs: [ + jobsRun(1, { display_title: 'process #703 · S7-code-review · x' }), + jobsRun(2, { name: 'Проверка (CI)', display_title: 'x', event: 'push' }), + ] }; + } + if (path === 'repos/o/r/actions/runs/1/jobs?per_page=100&page=1') return { jobs: [job('dev / Ревью: работа модели', 10, 10.5)] }; + if (path.startsWith('repos/o/r/actions/runs/2/jobs')) throw new Error('HTTP 410'); + if (path.startsWith('repos/o/r/issues/701/timeline')) return full; + if (path.startsWith('repos/o/r/issues/')) return [labeled('S1-new', 0)]; + throw new Error(`unexpected ${path}`); + }; + const git = (args) => { + if (args[0] === 'ls-tree') return 'docs/reviews/SHIP-REVIEW-v1.79.0-beta.2.md\ndocs/reviews/CODE-REVIEW-701-r1.md\n'; + if (args[0] === 'grep') throw new Error('exit 1'); + if (args[0] === 'show') return 'ship doc'; + if (args[0] === 'rev-parse') return 'f'.repeat(40); + if (args[0] === 'log') return `\x1e${'a'.repeat(40)}\x1f2026-09-30T00:00:00Z\x1fx\n\nIssue: #701\n\x1f\n3\t1\tsrc/a.ts\n`; + throw new Error(`git ${args.join(' ')}`); + }; + const snap = fetchSnapshot({ repo: 'o/r', since: T(0), until: T(48), gh, git }); + assert.ok(calls[0].includes('state=all') && calls[0].includes(`since=${encodeURIComponent('2026-08-31T00:00:00.000Z')}`), calls[0]); + assert.deepEqual(snap.issues.map((i) => i.number), [702], 'прежняя выборка — закрытые в окне'); + assert.deepEqual(snap.allIssues.map((i) => i.number), [701, 702, 703], 'S8, закрытые и задачи прогонов конвейера'); + assert.equal(calls.filter((c) => c.startsWith('repos/o/r/issues/701/timeline')).length, TIMELINE_PAGE_CAP); + assert.deepEqual([...snap.timelineTruncated], [701]); + assert.equal(snap.jobsByRun.get(1).length, 1); + assert.equal(snap.jobsByRun.get(2), null); + assert.deepEqual(snap.reviewDocs, [{ path: 'docs/reviews/SHIP-REVIEW-v1.79.0-beta.2.md', text: 'ship doc' }]); + assert.equal(issueChanges(snap.commits).get(701).lines, 4); +});