From b7dbc21c425e90be497057bfc9ee4e90a3a6a9e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 16:59:05 +0300 Subject: [PATCH] fix(process-metrics): task volume, merge return reasons and spec drafts (#752) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The weekly report disagreed with its own definitions (#728) and with the pipeline texts that appeared after it (#705, #723, #729). Volume. A task's volume counted documentation, so the archive move of #682 weighed 333 060 lines and #680/#681 thousands, all landing in the "> 1000" bucket and shifting the cohort medians; and every commit with a Release: trailer was dropped, including the task's own golden acceptance and test re-pinning commits. Volume is now the +/- of class A and B files only. Only beta commits are left out: the beta or release candidate (Release: trailer plus the candidate subject or a changed bundle, bundle-policy.mjs, drops every Release: commit, so it is not reused. A task whose commits touch only docs/reviews/** has no volume and is no longer read as infrastructure: the pipeline writes those documents, not the task. Return reasons. Every non-merge outcome of merge-candidate.mjs fell to "unknown". merge-candidate.mjs now exports a sign for the heading of each outcome comment (OUTCOME_SIGNS; the step-failure text moved into commentFor as 'error', byte for byte), and a test on the templates themselves holds every case to its own sign. The report maps merge-stage outcomes after a green verdict to "merge" and a push refused while rebasing before review to the new "push-refused" reason. Spec drafts. A new section after "По трекам" counts the S4-spec-review epochs on the ask track for tasks whose first S5-ready falls in the window, the epochs with a "Черновик:" comment (§7.2) and whether the draft went to S5 or was thrown at S3, Spec-Draft: commits in dev for the window, and S5 -> S7 per track for tasks with and without a draft, "мало данных" under three. The snapshot also reads timelines of tasks in S5-S7. Three #728 assertions pinned the old behaviour (a Release: fixture without the candidate subject, and the rebase workflow refusal read as unknown); they now expect the new definitions. Issue: #752 User-Visible: no Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd --- scripts/merge-candidate.mjs | 31 ++++- scripts/process-metrics.mjs | 220 +++++++++++++++++++++++++++++--- test/merge-candidate.test.mjs | 40 +++++- test/process-metrics.test.mjs | 228 ++++++++++++++++++++++++++++++++-- 4 files changed, 489 insertions(+), 30 deletions(-) diff --git a/scripts/merge-candidate.mjs b/scripts/merge-candidate.mjs index 3c623e20..44bf137b 100755 --- a/scripts/merge-candidate.mjs +++ b/scripts/merge-candidate.mjs @@ -226,11 +226,40 @@ export function commentFor(action, ctx) { + ' · слито' + (ctx.branchDeleted === true ? ` · ветка \`${ctx.branch}\` удалена` : '') + (ctx.branchDeleted === false ? ` · ветка \`${ctx.branch}\` оставлена: её вершина сдвинулась после слияния` : ''); + // Сбой самого шага (`action=error`): `ctx.error` — уже без секретов и не длиннее 1500 знаков. + case 'error': + return `**Слияние не выполнено: сбой шага слияния (#492).**\n\n\`\`\`\n${ctx.error ?? ''}\n\`\`\`\n\n` + + 'Вердикт ревью в силе. Задача в `S6-in-progress`; после разбора сбоя вернуть `S7-code-review`.'; default: return ''; } } +/** + * #752: признаки исходов — заголовок каждого комментария `commentFor`, кроме + * успешного слияния. `stage` — где исход случается: `merge` — слияние после + * зелёного вердикта, `rebase` — страж ребейза до ревью (`describePushRefusal`, + * `_process.yml`). Отчёт процесса (`process-metrics.mjs`) узнаёт по ним + * причину возврата; переименование заголовка без признака краснит тест на + * самих шаблонах, а не даёт молча `unknown`. + */ +export const OUTCOME_SIGNS = Object.freeze([ + { action: 'reject-stale', stage: 'merge', re: /^\*\*Слияние отменено: ветка изменилась после проверенного материала/m }, + { action: 'conflict', stage: 'merge', re: /^\*\*Код-ревью зелёное — вердикт выше в силе, переделывать работу не нужно\.\*\* Не удалось только слияние/m }, + { action: 'rereview', stage: 'merge', re: /^\*\*Дифф изменился при ребейзе на `dev@[^`]*` — вердикт к нему не применим/m }, + { action: 'validation-red', stage: 'merge', re: /^\*\*Кандидат после ребейза на `dev@[^`]*` красный/m }, + { action: 'validation-missing', stage: 'merge', re: /^\*\*Validate на кандидате `[^`]*` не появился за \d+ мин/m }, + { action: 'give-up', stage: 'merge', re: /^\*\*`dev` движется быстрее слияния:/m }, + { action: 'push-refused-workflow', stage: 'merge', re: /^\*\*Слияние не выполнено: кандидат меняет workflow-файл/m }, + { action: 'push-refused-workflow', stage: 'rebase', re: /^\*\*Ревью не запускалось: кандидат меняет workflow-файл/m }, + { action: 'push-refused', stage: 'merge', re: /^\*\*Слияние не выполнено: GitHub отклонил push /m }, + { action: 'push-refused', stage: 'rebase', re: /^\*\*Ревью не запускалось: GitHub отклонил push /m }, + { action: 'error', stage: 'merge', re: /^\*\*Слияние не выполнено: сбой шага слияния/m }, +].map((sign) => Object.freeze(sign))); + +/** Исход по тексту комментария: признак `OUTCOME_SIGNS` или `null`. */ +export const outcomeOf = (body) => OUTCOME_SIGNS.find((sign) => sign.re.test(String(body ?? ''))) || null; + // --------------------------------------------------------------------------- // Исполнение: git + gh через `ops`, чтобы тест подменял их целиком. @@ -604,7 +633,7 @@ else if (isMainModule(import.meta.url)) { // #496: переносимо для W // #705: текст сбоя уходит в issue — без токена и URL с учётными данными. console.error(redactSecrets(err && err.stack || err, [token])); try { - ops.comment(issue, `**Слияние не выполнено: сбой шага слияния (#492).**\n\n\`\`\`\n${redactSecrets(String(err && err.message || err), [token]).slice(0, 1500)}\n\`\`\`\n\nВердикт ревью в силе. Задача в \`S6-in-progress\`; после разбора сбоя вернуть \`S7-code-review\`.`); + ops.comment(issue, commentFor('error', { error: redactSecrets(String(err && err.message || err), [token]).slice(0, 1500) })); } catch (e) { console.error(e); } if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, 'merged=false\nto=S6-in-progress\naction=error\n'); process.exit(0); diff --git a/scripts/process-metrics.mjs b/scripts/process-metrics.mjs index af11a0f0..9930fcb3 100644 --- a/scripts/process-metrics.mjs +++ b/scripts/process-metrics.mjs @@ -21,6 +21,15 @@ // // #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] @@ -35,6 +44,8 @@ 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)(?: ·|$)/; @@ -195,7 +206,7 @@ export const JOBS_RUN_CAP = 600; 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 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)'; @@ -225,6 +236,19 @@ 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)); /** События меток таймлайна в порядке времени (при равном времени — в порядке таймлайна). */ @@ -362,12 +386,15 @@ export function stageVerdict(body, { stage = 'code', number } = {}) { /** * К3. Признак причины возврата в одном комментарии; `null` — комментарий * причины не называет. «Ревью не запускалось» с другим продолжением — - * `unknown`: семейство узнано, причина не угадывается. + * `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'; @@ -530,24 +557,45 @@ export function readCommits(git, { ref = 'origin/dev', since } = {}) { return parseGitLog(git(args)); } -/** Строка объёма К7: не класс D (`classify`) и не `docs/reviews/**`. */ -const countsToVolume = (path) => classify(path) !== 'D' && !String(path).startsWith('docs/reviews/'); +/** Строка объёма К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/'); /** - * Изменения задачи по коммитам с трейлером `Issue: #NN`, кроме коммитов - * `Release:` (они несут трейлеры всех задач беты, бандлы и версию манифеста): - * объём К7 (`+/−` без класса D и `docs/reviews/**`) и файлы для признака - * инфраструктуры К1. + * Подпись коммита бота `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 (/^Release:/m.test(String(commit.body || ''))) continue; + 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 || []) { - entry.files.add(file.path); + 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); @@ -556,9 +604,15 @@ export function issueChanges(commits = []) { return byIssue; } -/** Инфраструктура (§1): в коммитах задачи есть файлы и ни одного класса A. */ +/** + * Инфраструктура (§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; @@ -578,9 +632,10 @@ export function issueTrackMetrics(issue, events = [], { change = null, ship = nu 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: change ? change.lines : null, bucket: change ? volumeBucket(change.lines) : null, + 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); @@ -654,6 +709,100 @@ export function trackSection(rows = [], { shipDocs = [] } = {}) { }; } +// --------------------------------------------------------------------------- +// #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); @@ -745,8 +894,8 @@ export function stageMinutes({ runs = [], jobsByRun = new Map(), trackOf = () => * К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); @@ -860,6 +1009,7 @@ function trackReport({ since, until, issues, timelines, timelineTruncated, revie }; 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 @@ -914,6 +1064,7 @@ export function renderMarkdown(report) { 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); @@ -975,6 +1126,38 @@ function renderTracks(lines, t) { } } +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-минуты по стадиям'); @@ -1031,7 +1214,7 @@ 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(`Первый \`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('Мало данных: в окне сравнения нет задач с коммитами.'); @@ -1049,7 +1232,7 @@ function renderCompare(lines, c) { } if (c.noCommits.length) { lines.push(''); - lines.push(`Без коммитов с трейлером (объёма нет, в когорты не входят): ${c.noCommits.map((n) => `#${n}`).join(', ')}.`); + lines.push(`Без коммитов с трейлером или только с документами ревью (объёма нет, в когорты не входят): ${c.noCommits.map((n) => `#${n}`).join(', ')}.`); } } @@ -1066,7 +1249,7 @@ function ghJson(args) { * - issue `state=all` с `since` самого раннего окна (неделя отчёта или окно * сравнения К7): задачи в `S8-merged` закрываются только с бетой (§2.8). * Прежняя выборка (`issues`) — закрытые в окне, как раньше; - * - таймлайн — у прежней выборки, у задач с `S8-merged` или закрытых и у + * - таймлайн — у прежней выборки, у задач с `S5-ready`…`S8-merged` или закрытых и у * задач прогонов конвейера окна; не дальше `TIMELINE_PAGE_CAP` страниц, * иначе задача помечается «таймлайн усечён»; * - jobs (К5) — для `jobRuns`, недоступные jobs — `null`; @@ -1101,8 +1284,9 @@ export function fetchSnapshot({ repo, since, until, gh = ghJson, git = null, com } 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) => (label?.name ?? label) === 'S8-merged') + || (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(); diff --git a/test/merge-candidate.test.mjs b/test/merge-candidate.test.mjs index d13aa614..d2c1e6d7 100755 --- a/test/merge-candidate.test.mjs +++ b/test/merge-candidate.test.mjs @@ -1,14 +1,14 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync, spawnSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { MAX_ATTEMPTS, MAX_COMMAND_OUTPUT_BYTES, PUSH_REFUSAL, PushRefusal, classifyPushRefusal, commentFor, decideMerge, - describePushRefusal, mergeCandidate, realOps, redactSecrets, sh, + describePushRefusal, mergeCandidate, realOps, redactSecrets, sh, OUTCOME_SIGNS, outcomeOf, } from '../scripts/merge-candidate.mjs'; import { buildCiProof } from '../scripts/ci-proof.mjs'; import { buildIndex } from '../scripts/reviews-index.mjs'; @@ -877,3 +877,39 @@ test('#705 на настоящем git: отказ сервера `[remote rejec rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); } }); + +// #752: признаки исходов — заголовки самих шаблонов. Отчёт процесса узнаёт по +// ним причину возврата; заголовок, переименованный без признака, краснит здесь. +test('#752 AC2: каждый шаблон исхода commentFor и describePushRefusal узнаётся своим признаком и только им', () => { + const ctx = { + material: 'a'.repeat(40), actual: 'b'.repeat(40), candidate: 'c'.repeat(40), devNow: 'd'.repeat(40), + branch: 'issue/752-x', ref: 'dev', runUrl: 'https://github.com/o/r/actions/runs/1', attempt: 3, error: 'git push dev: boom', + refusal: { kind: PUSH_REFUSAL.workflow, reason: 'r', files: ['.github/workflows/x.yml'], stderr: 'e' }, pipelineUrl: 'https://run/1', + }; + const outcomes = [ + ['reject-stale', 'merge'], ['conflict', 'merge'], ['rereview', 'merge'], ['validation-red', 'merge'], + ['validation-missing', 'merge'], ['give-up', 'merge'], ['push-refused-workflow', 'merge'], ['push-refused-workflow', 'rebase'], + ['push-refused', 'merge'], ['push-refused', 'rebase'], ['error', 'merge'], + ]; + const key = (sign) => `${sign.action}/${sign.stage}`; + for (const [action, stage] of outcomes) { + const text = commentFor(action, { ...ctx, stage }); + assert.ok(text, `${action}: шаблон не пуст`); + assert.deepEqual(OUTCOME_SIGNS.filter((sign) => sign.re.test(text)).map(key), [`${action}/${stage}`], `${action} (${stage}) — ровно свой признак`); + assert.equal(key(outcomeOf(text)), `${action}/${stage}`); + } + assert.deepEqual(OUTCOME_SIGNS.map(key).sort(), outcomes.map(([action, stage]) => `${action}/${stage}`).sort(), 'признак на каждый исход, лишних нет'); + for (const action of ['push', 'fast-forward']) assert.equal(outcomeOf(commentFor(action, ctx)), null, `${action} — слито, не исход возврата`); + // Каждый case шаблонов — либо слияние, либо исход с признаком: новый case без признака краснит здесь. + const source = readFileSync(fileURLToPath(new URL('../scripts/merge-candidate.mjs', import.meta.url)), 'utf8'); + const body = source.slice(source.indexOf('export function commentFor('), source.indexOf('\n}\n', source.indexOf('export function commentFor('))); + const cases = [...body.matchAll(/^\s+case '([a-z-]+)':/gm)].map((m) => m[1]); + assert.deepEqual([...new Set(cases)].sort(), [...new Set([...outcomes.map(([action]) => action), 'push', 'fast-forward'])].sort()); + // Страж ребейза (`_process.yml`) получает текст из describePushRefusal. + const rebase = (reason) => describePushRefusal(remoteRejected(reason), { ref: 'issue/752-x', branch: 'issue/752-x', candidate: 'c'.repeat(40), stage: 'rebase' }).comment; + assert.equal(key(outcomeOf(rebase(WORKFLOW_REFUSALS['GitHub App / GITHUB_TOKEN']))), 'push-refused-workflow/rebase'); + assert.equal(key(outcomeOf(rebase('protected branch hook declined'))), 'push-refused/rebase'); + // Сбой шага: тот же текст, что писал прежний inline-шаблон. + assert.equal(commentFor('error', { error: 'boom' }), + '**Слияние не выполнено: сбой шага слияния (#492).**\n\n```\nboom\n```\n\nВердикт ревью в силе. Задача в `S6-in-progress`; после разбора сбоя вернуть `S7-code-review`.'); +}); diff --git a/test/process-metrics.test.mjs b/test/process-metrics.test.mjs index 4e5ac4a0..e8305490 100644 --- a/test/process-metrics.test.mjs +++ b/test/process-metrics.test.mjs @@ -7,12 +7,12 @@ 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, reviewDocAddedAt, shipFindings, stageMinutes, tokenDocs, tokenUsage, trackAt, trackPath, trackSection, volumeBucket, + BETA_DERIVED_SUBJECT, DRAFT_COMMENT_RE, NOT_RUN_CONFLICT_RE, NOT_RUN_VALIDATE_RE, RETURN_REASONS, TIMELINE_PAGE_CAP, TOKENS_NO_DATA, TRACKS_CUTOVER, + changeVolume, compareCohorts, draftSection, fetchSnapshot, isBetaCommit, isInfra, issueChanges, issueSegments, issueTrackMetrics, jobRuns, jobStage, + readCommits, returnSignal, reviewDocAddedAt, shipFindings, specEpochs, stageMinutes, tokenDocs, 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 { commentFor, describePushRefusal } from '../scripts/merge-candidate.mjs'; import { anchorBlock } from '../scripts/ship-review.mjs'; import { withMaterialAnchors } from '../scripts/review-doc-guard.mjs'; import { formatUsage } from '../scripts/model-usage.mjs'; @@ -201,11 +201,11 @@ test('#728 trackAt: трек на момент события и путь тре assert.equal(trackAt(both, T(1)), labelTrack({ labels: ['track:ship', 'track:ask'] })); assert.equal(trackAt(both, T(1)), 'ask'); - // infra — ни одного файла класса A в коммитах задачи; Release:-коммит не в счёт. + // infra — ни одного файла класса A в коммитах задачи; коммит кандидата беты не в счёт (#752). 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' }] }, + { sha: 'c'.repeat(40), body: 'Release v1.0.0-beta.1 candidate\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 }); @@ -281,7 +281,7 @@ test('#728 returnSignal: причины возврата на текстах к 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(refused), 'push-refused', '#752: отказ push по праву на workflow на ребейзе (#705) — признак merge-candidate.mjs'); assert.equal(sig('Текст.\n\n'), 'reclassify'); assert.equal(sig('Текст.\n\n'), 'owner-question'); assert.equal(sig('Пока шло ревью, `dev` продвинулся на 2 коммит(ов).'), null); @@ -332,7 +332,7 @@ test('#728 returnReason: последний комментарий с призн 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', + 'unknown', 'unknown', 'unknown', 'push-refused', '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', @@ -342,7 +342,7 @@ test('#728 returnReason: последний комментарий с призн 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(['unknown', 'push-refused'].map((reason) => section.events.find((e) => e.track === 'show').reasons[reason]), [3, 1]); // Раунды — объявления вердикта этапа, по треку и блокирующие/зелёные. 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 }); @@ -681,3 +681,213 @@ test('#761 токены за окно: документ вне окна, пер assert.equal(tokenDocs(snap.reviewDocs).length, 5); assert.equal(tokenDocs(snap.reviewDocs, { added: new Map(), since, until }).length, 0, 'добавление не найдено — вне окна'); }); + +// #752: объём задачи, причины возвратов слияния, черновик ТЗ. + +/** Временный git-репозиторий: коммиты и `readCommits` по нему. */ +function tempRepo(t, prefix) { + const dir = mkdtempSync(join(tmpdir(), prefix)); + 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, tag = 'l') => { + mkdirSync(join(dir, dirname(path)), { recursive: true }); + writeFileSync(join(dir, path), `${Array.from({ length: lines }, (_, k) => `${tag}${k}`).join('\n')}\n`); + }; + const commit = (message) => { git('add', '-A'); git('commit', '-q', '-m', message); }; + const commits = () => readCommits((args) => git(...args), { ref: 'HEAD', since: '2000-01-01' }); + return { dir, git, write, commit, commits }; +} + +test('#752 AC1 объём: классы A и B; документация, перенос в архив и документы ревью — нет', (t) => { + const repo = tempRepo(t, 'hp-752-volume-'); + repo.write('docs/reviews/R.md', 3); + repo.commit('docs: base'); + repo.write('src/a.ts', 10); repo.write('docs/x.md', 5000); + repo.commit('feat: a (#751)\n\nIssue: #751\nUser-Visible: no'); + mkdirSync(join(repo.dir, 'legacy/reviews/v1'), { recursive: true }); + repo.git('mv', 'docs/reviews/R.md', 'legacy/reviews/v1/R.md'); + repo.commit('docs: archive (#751)\n\nIssue: #751\nUser-Visible: no'); + repo.write('docs/reviews/SPEC-REVIEW-7-r1.md', 40); + repo.commit('docs: review document for #7\n\nIssue: #7\nUser-Visible: no'); + let changes = issueChanges(repo.commits()); + assert.equal(changeVolume(changes.get(751)), 10, 'src +10; docs/x.md +5000 и перенос R.md в legacy/reviews — не объём'); + assert.equal(volumeBucket(changeVolume(changes.get(751))), '≤30'); + assert.equal(isInfra(changes.get(751)), false); + assert.equal(isInfra(changes.get(7)), false, 'только документ ревью — признак инфраструктуры не доказан'); + assert.equal(changeVolume(changes.get(7)), null, 'только документ ревью — объёма нет'); + repo.write('test/a.test.mjs', 40); + repo.commit('test: a (#751)\n\nIssue: #751\nUser-Visible: no'); + changes = issueChanges(repo.commits()); + assert.equal(changeVolume(changes.get(751)), 50, 'тесты — класс B, входят'); + // Строка задачи без объёма — «без коммитов» сравнения, а не корзина «≤30». + const row = issueTrackMetrics({ number: 7 }, [labeled('S4-spec-review', 0), labeled('S8-merged', 1)], { change: changes.get(7) }); + assert.deepEqual([row.infra, row.volume, row.bucket], [false, null, null]); +}); + +test('#752 AC1 объём: Release:-коммиты задачи входят, кандидат беты, промоушен и бот beta-derived — нет', (t) => { + const repo = tempRepo(t, 'hp-752-release-'); + repo.write('src/a.ts', 10); + repo.commit('feat: a (#751)\n\nIssue: #751\nUser-Visible: no'); + // Приёмка эталонов задачи: аттестация (класс B) и кадры (класс D). + repo.write('demo/golden/attestation.json', 6); repo.write('demo/golden/baselines/scene.png', 50); + repo.commit('test(golden): accept the reviewed frames (#751)\n\nIssue: #751\nUser-Visible: no\nRelease: v1.0.0-beta.2\nBaseline-Reviewed: https://github.com/o/r/actions/runs/1'); + // Перепривязка тестов к бете. + repo.write('test/b.test.mjs', 4); + repo.commit('test(daycycle): witnesses follow the beta (#751)\n\nRelease: v1.0.0-beta.2\nIssue: #751\nUser-Visible: no'); + // Кандидат беты: трейлеры всех задач линии, версия и бандл. + repo.write('src/version.ts', 300, 'v'); repo.write('dist/houseplan-card.js', 500); + repo.commit('Release v1.0.0-beta.2 candidate\n\nIssue: #751\nIssue: #752\nUser-Visible: yes\nRelease: v1.0.0-beta.2'); + // Кандидат без пересобранного бандла — по подписи. + repo.write('package.json', 7, 'p'); + repo.commit('Release v1.0.0-beta.2 candidate refresh\n\nIssue: #751\nRelease: v1.0.0-beta.2'); + // Промоушен стабильного — по бандлу. + repo.write('src/version.ts', 300, 'w'); repo.write('dist/houseplan-card.js', 500, 'd'); + repo.commit('release: promote v1.0.0 from beta line\n\nIssue: #751\nIssue: #752\nUser-Visible: yes\nRelease: v1.0.0'); + // Бот beta-derived (#697): отпечаток скриншотов и эталоны на dev. + repo.write('docs/images/screenshots.json', 12); repo.write('demo/golden/baselines/other.png', 30); + repo.commit('docs: accept derived artifacts on dev for v1.0.0-beta.3\n\nПроизводные артефакты беты.\n\nRelease: v1.0.0-beta.3\nBaseline-Reviewed: https://github.com/o/r/actions/runs/2\nIssue: #697\nUser-Visible: no'); + const commits = repo.commits(); + assert.deepEqual(commits.filter(isBetaCommit).map((c) => c.body.split('\n')[0]), [ + 'docs: accept derived artifacts on dev for v1.0.0-beta.3', 'release: promote v1.0.0 from beta line', + 'Release v1.0.0-beta.2 candidate refresh', 'Release v1.0.0-beta.2 candidate', + ]); + const changes = issueChanges(commits); + assert.equal(changeVolume(changes.get(751)), 20, 'src 10 + аттестация 6 + тест 4; кадры (D) и коммиты беты — нет'); + assert.equal(changes.has(752), false, 'задача, названная только кандидатом, объёма не получает'); + assert.equal(changes.has(697), false, 'коммит бота — не работа #697'); + // Текст в теле, начатый словом «Release:», — не трейлер: коммит задачи остаётся коммитом задачи. + assert.equal(isBetaCommit({ body: 'Release v1.0.0 candidate notes\n\n- dist/x\nRelease: notes wrap here\n\nIssue: #1\n', files: [{ path: 'dist/x.js' }] }), false); +}); + +test('#752 контракт: подпись коммита бота beta-derived — из _beta-derived.yml', () => { + const workflow = readFileSync(new URL('../.github/workflows/_beta-derived.yml', import.meta.url), 'utf8'); + const echo = /echo "(docs: accept derived artifacts on dev for )\$TAG"/.exec(workflow); + assert.ok(echo, 'тема коммита бота в _beta-derived.yml'); + assert.match(`${echo[1]}v1.79.0-beta.1`, BETA_DERIVED_SUBJECT); + assert.doesNotMatch('docs: accept the #689 source fingerprint', BETA_DERIVED_SUBJECT); +}); + +test('#752 AC2 причины: исходы слияния после зелёного вердикта — merge, отказ push на ребейзе — push-refused', () => { + const ctx = { material: 'a'.repeat(40), candidate: 'c'.repeat(40), devNow: 'd'.repeat(40), branch: 'issue/701-x', ref: 'dev', runUrl: 'https://run/1', attempt: 3 }; + const remote = (reason) => `To https://github.com/o/r\n ! [remote rejected] 0123abcd -> dev (${reason})\nerror: failed to push some refs`; + const mergeRefused = commentFor('push-refused', { ...ctx, stage: 'merge', refusal: { kind: 'remote-rejected', reason: 'protected branch hook declined', stderr: 'x' } }); + const mergeWorkflow = commentFor('push-refused-workflow', { ...ctx, stage: 'merge', refusal: { kind: 'workflow', files: ['.github/workflows/x.yml'] } }); + const merge = [ + commentFor('validation-red', ctx), commentFor('validation-missing', ctx), commentFor('give-up', ctx), + mergeRefused, mergeWorkflow, commentFor('error', { error: 'git push dev: boom' }), + ]; + assert.match(mergeRefused, /^\*\*Слияние не выполнено: GitHub отклонил push/); + assert.match(merge.at(-1), /^\*\*Слияние не выполнено: сбой шага слияния/); + const rebaseWorkflow = describePushRefusal(remote('refusing to allow a GitHub App to create or update workflow `.github/workflows/x.yml` without `workflows` permission'), + { ref: 'issue/701-x', branch: 'issue/701-x', candidate: 'c'.repeat(40), stage: 'rebase' }).comment; + assert.match(rebaseWorkflow, /^\*\*Ревью не запускалось: кандидат меняет workflow-файл/); + const rebaseRemote = describePushRefusal(remote('protected branch hook declined'), { ref: 'issue/701-x', branch: 'issue/701-x', stage: 'rebase' }).comment; + const sig = (body) => returnSignal(body, { stage: 'code', number: NUM }); + for (const text of merge) assert.equal(sig(text), 'merge', text.split('\n')[0]); + assert.equal(sig(rebaseWorkflow), 'push-refused'); + assert.equal(sig(rebaseRemote), 'push-refused'); + assert.equal(sig(commentFor('rereview', ctx)), null, 'дифф изменился — задача идёт в S7, а не автору: причины нет'); + assert.ok(RETURN_REASONS.includes('push-refused')); + + // В окне: зелёный вердикт, затем исход слияния; возврат без комментария — unknown. + const GREEN = `Вердикт: зелёный · заход r1 · High: 0 · Документ: docs/reviews/CODE-REVIEW-${NUM}-r1.md`; + const cycle = (h, comments) => [ + labeled('S7-code-review', h), ...comments.map((body, k) => commented(body, h + 0.1 + k * 0.1)), labeled('S6-in-progress', h + 0.5), + ]; + const events = [ + labeled('S1-new', 0), labeled('track:show', 0), labeled('S6-in-progress', 1), + ...merge.flatMap((text, k) => cycle(2 + k, [GREEN, text])), + ...cycle(10, [rebaseWorkflow]), + ...cycle(11, []), + labeled('S7-code-review', 12), commented(GREEN, 12.1), labeled('S8-merged', 12.5), + ]; + const row = issueTrackMetrics({ number: NUM }, events); + assert.deepEqual(row.returns.map((r) => r.reason), [...merge.map(() => 'merge'), 'push-refused', 'unknown']); + const show = trackSection([row]).events.find((e) => e.track === 'show'); + assert.deepEqual([show.reasons.merge, show.reasons['push-refused'], show.reasons.unknown], [6, 1, 1]); + const md = renderMarkdown(buildReport({ since: T(0), until: T(48), issues: [{ number: NUM }], timelines: new Map([[NUM, events]]) })); + assert.match(md, /\| Трек \| Возвратов \| verdict-yellow \| verdict-red \| validate-red \| conflict \| push-refused \| merge \|/); + assert.match(md, /\| show \| 8 \| 0 \| 0 \| 0 \| 0 \| 1 \| 6 \| 0 \| 0 \| 1 \|/); +}); + +const DRAFT = 'Черновик: автор · сессия s1 · локальная ветка issue/760-x · Spec-Draft sha256:0123456789ab'; + +test('#752 AC3 черновик ТЗ: эпохи S4 на ask, черновик выброшен или пошёл в дело, show не в счёт', () => { + const ask = (n, draftEpoch) => [ + labeled('S1-new', 0), labeled('track:ask', 0), labeled('S3-spec', 0.5), + labeled('S4-spec-review', 1), ...(draftEpoch === 1 ? [commented(DRAFT, 2)] : []), labeled('S3-spec', 3), + labeled('S4-spec-review', 4), labeled('S4-spec-review', 4.2), ...(draftEpoch === 2 ? [commented(DRAFT, 4.5)] : []), labeled('S5-ready', 5), + labeled('S6-in-progress', 5.5), labeled('S7-code-review', 5 + n), + ]; + assert.deepEqual(specEpochs(ask(1, 1)).map((e) => [e.close, (e.end - e.start) / HOUR]), [['S3-spec', 2], ['S5-ready', 1]], 'повторная S4 эпоху не начинает'); + const thrown = draftSection({ issues: [{ number: 760 }], timelines: new Map([[760, ask(1, 1)]]), since: T(0), until: T(48) }); + assert.deepEqual(thrown.epochs, { total: 2, withDraft: 1, used: 0, thrown: 1, other: 0 }); + const used = draftSection({ issues: [{ number: 760 }], timelines: new Map([[760, ask(1, 2)]]), since: T(0), until: T(48) }); + assert.deepEqual(used.epochs, { total: 2, withDraft: 1, used: 1, thrown: 0, other: 0 }); + // show: эпоха S4 не на ask — в счёт не входит. + const show = [labeled('S1-new', 0), labeled('track:show', 0), labeled('S4-spec-review', 1), commented(DRAFT, 1.5), labeled('S5-ready', 2)]; + const onlyShow = draftSection({ issues: [{ number: 761 }], timelines: new Map([[761, show]]), since: T(0), until: T(48) }); + assert.deepEqual([onlyShow.tasks, onlyShow.epochs.total, onlyShow.epochs.withDraft], [1, 0, 0]); + // Первый S5 вне окна — задачи нет. + assert.equal(draftSection({ issues: [{ number: 760 }], timelines: new Map([[760, ask(1, 2)]]), since: T(6), until: T(48) }).tasks, 0); + + // Нет черновиков — строка «черновиков нет», а не ноль в таблице. + const none = renderMarkdown(buildReport({ since: T(0), until: T(48), issues: [{ number: 762 }], timelines: new Map([[762, ask(1, 0)]]) })); + const section = none.slice(none.indexOf('### Черновик ТЗ (#729)'), none.indexOf('### Токены')); + assert.match(section, /Черновиков нет: эпох `S4-spec-review` на ask — 2, комментария «Черновик:» нет ни в одной\./); + assert.doesNotMatch(section, /\| Эпох S4/); + assert.ok(none.indexOf('### По трекам') < none.indexOf('### Черновик ТЗ (#729)'), 'раздел — после «По трекам»'); + const drafted = renderMarkdown(buildReport({ since: T(0), until: T(48), issues: [{ number: 760 }], timelines: new Map([[760, ask(1, 1)]]) })); + assert.match(drafted, /\| Эпох S4 \(ask\) \| С черновиком \| Пошёл в дело \(S5\) \| Выброшен \(S3\) \| Открыта или иначе \|\n\|---:\|---:\|---:\|---:\|---:\|\n\| 2 \| 1 \| 0 \| 1 \| 0 \|/); +}); + +test('#752 AC3 черновик ТЗ: S5 → S7 с черновиком и без по трекам, n < 3 — мало данных; коммиты Spec-Draft за окно', () => { + const ask = (hoursToS7, draft) => [ + labeled('S1-new', 0), labeled('track:ask', 0), labeled('S4-spec-review', 1), ...(draft ? [commented(DRAFT, 1.5)] : []), + labeled('S5-ready', 2), labeled('S6-in-progress', 2.1), labeled('S7-code-review', 2 + hoursToS7), + ]; + const ship = (hoursToS7) => [labeled('S1-new', 0), labeled('track:ship', 0), labeled('S5-ready', 2), labeled('S7-code-review', 2 + hoursToS7)]; + // Черновик выброшен в первой эпохе, эпоху, закрытую S5, вёл не черновик — «без черновика». + const thrownThenPlain = [ + labeled('S1-new', 0), labeled('track:ask', 0), labeled('S4-spec-review', 0.2), commented(DRAFT, 0.3), labeled('S3-spec', 0.5), + labeled('S4-spec-review', 1), labeled('S5-ready', 2), labeled('S7-code-review', 32), + ]; + const timelines = new Map([ + [1, ask(1, true)], [2, ask(2, true)], [3, ask(3, true)], [4, ask(10, false)], [5, thrownThenPlain], + [6, ship(4)], [7, ship(5)], [8, ship(6)], [9, ask(100, true)], + ]); + const commits = [ + { sha: 'a'.repeat(40), date: T(3), body: 'feat: x (#1)\n\nIssue: #1\nSpec-Draft: sha256:' + 'f'.repeat(64) + '\n', issues: [1], files: [] }, + { sha: 'b'.repeat(40), date: T(60), body: 'feat: y (#2)\n\nIssue: #2\nSpec-Draft: sha256:' + 'e'.repeat(64) + '\n', issues: [2], files: [] }, + { sha: 'c'.repeat(40), date: T(3), body: 'feat: z (#3)\n\nIssue: #3\n', issues: [3], files: [] }, + ]; + const d = draftSection({ issues: [...timelines.keys()].map((number) => ({ number })), timelines, commits, since: T(0), until: T(48) }); + const byTrack = Object.fromEntries(d.s5ToS7.map((row) => [row.track, row])); + assert.deepEqual(byTrack.ask.draft, { n: 3, hours: 2, enough: true }); + assert.deepEqual(byTrack.ask.plain, { n: 2, hours: 20, enough: false }); + assert.deepEqual(byTrack.ship.plain, { n: 3, hours: 5, enough: true }); + assert.deepEqual(byTrack.show, { track: 'show', draft: { n: 0, hours: null, enough: false }, plain: { n: 0, hours: null, enough: false } }); + assert.deepEqual(d.noS7, [9], 'S7 после конца окна — в S5 → S7 не входит'); + assert.deepEqual(d.specDraftCommits, { count: 1, issues: [1] }, 'коммит вне окна и без трейлера — нет'); + const md = renderMarkdown(buildReport({ since: T(0), until: T(48), issues: [...timelines.keys()].map((number) => ({ number })), timelines, commits })); + assert.match(md, /\| ask \| 3 \| 2 \| 2 \| мало данных \|/); + assert.match(md, /\| ship \| 0 \| мало данных \| 3 \| 5 \|/); + assert.match(md, /Коммиты с трейлером `Spec-Draft:` в `dev` за окно: 1 \(#1\)\./); + assert.match(md, /Без `S7-code-review` к концу окна \(в S5 → S7 не входят\): #9\./); +}); + +test('#752 контракт: комментарий «Черновик:» — шаблон PROCESS.md §7.2', () => { + const canon = readFileSync(new URL('../PROCESS.md', import.meta.url), 'utf8'); + const template = /- \*\*Черновик\*\* \(§11\.8\): `(Черновик: [^`]+)`/.exec(canon.replace(/\n {2}/g, ' ')); + assert.ok(template, 'шаблон «Черновик» в §7.2'); + assert.match(template[1], DRAFT_COMMENT_RE); + assert.match(DRAFT, DRAFT_COMMENT_RE); + assert.doesNotMatch('Взял: автор · сессия s1 · ветка issue/760-x', DRAFT_COMMENT_RE); +});