mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 12:49:56 +00:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
894 lines
66 KiB
JavaScript
894 lines
66 KiB
JavaScript
// #637: еженедельный замер процесса — чистые функции над снимками GitHub.
|
||
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
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,
|
||
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, 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';
|
||
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) });
|
||
|
||
test('#637 issueMetrics: вход по первой статусной метке, S7/S8 по первой постановке, повторы считаются отдельно', () => {
|
||
const m = issueMetrics({ number: 600, title: 't', closed_at: T(30), state_reason: 'completed' }, [
|
||
labeled('bug', 0), labeled('S2-analysis', 1), labeled('S3-spec', 2), labeled('S4-spec-review', 3),
|
||
labeled('S5-ready', 5), labeled('S6-in-progress', 6), labeled('S7-code-review', 10),
|
||
{ event: 'unlabeled', label: { name: 'S7-code-review' }, created_at: T(11) },
|
||
labeled('S7-code-review', 12), labeled('S8-merged', 20),
|
||
]);
|
||
assert.equal(m.enteredAt, Date.parse(T(1)), 'bug — не статус');
|
||
assert.equal(m.leadToS7Ms, 9 * 3_600_000);
|
||
assert.equal(m.reviewToMergeMs, 10 * 3_600_000);
|
||
assert.equal(m.leadToS8Ms, 19 * 3_600_000);
|
||
assert.equal(m.specLeadMs, 2 * 3_600_000);
|
||
assert.equal(m.s7Requests, 2);
|
||
assert.equal(m.s4Requests, 1);
|
||
const bare = issueMetrics({ number: 1, closed_at: 'x', state_reason: 'not_planned' }, []);
|
||
assert.equal(bare.leadToS7Ms, null);
|
||
assert.equal(bare.closedAt, null);
|
||
});
|
||
|
||
test('#637 reviewRounds: максимум раунда по документам, отдельно CODE и SPEC', () => {
|
||
const rounds = reviewRounds(['CODE-REVIEW-600-r1.md', 'CODE-REVIEW-600-r2.md', 'SPEC-REVIEW-600-r1.md', 'CODE-REVIEW-601-r1.md', 'README.md', 'CODE-REVIEW-issue-5.md']);
|
||
assert.equal(rounds.get('CODE:600'), 2);
|
||
assert.equal(rounds.get('SPEC:600'), 1);
|
||
assert.equal(rounds.get('CODE:601'), 1);
|
||
assert.equal(rounds.size, 3);
|
||
});
|
||
|
||
const run = (over) => ({
|
||
name: 'Проверка (CI)', event: 'push', conclusion: 'success', run_started_at: T(0), updated_at: T(1), ...over,
|
||
});
|
||
|
||
test('#637 runMetrics: по workflow — исходы, wall-time, события; прогоны конвейера сведены в одну строку', () => {
|
||
const rows = runMetrics([
|
||
run(), run({ conclusion: 'failure', event: 'workflow_dispatch' }), run({ conclusion: 'cancelled', updated_at: T(0) }),
|
||
run({ name: 'Ревью-конвейер', display_title: 'process #600 · S7-code-review · x', event: 'issues', updated_at: T(2) }),
|
||
run({ name: 'Ревью-конвейер', display_title: 'process #600 · bug · x', event: 'issues', conclusion: 'skipped', updated_at: T(0) }),
|
||
]);
|
||
const validate = rows.find((r) => r.workflow === 'Проверка (CI)');
|
||
assert.deepEqual([validate.runs, validate.success, validate.failure, validate.cancelled], [3, 1, 1, 1]);
|
||
assert.equal(validate.wallMs, 2 * 3_600_000, 'отменённый без длительности не считается');
|
||
assert.deepEqual(validate.events, { push: 2, workflow_dispatch: 1 });
|
||
const process = rows.find((r) => r.workflow === 'process');
|
||
assert.deepEqual([process.runs, process.skipped], [2, 1]);
|
||
});
|
||
|
||
test('#637 pipelineMetrics: минуты S7/S4 по имени прогона, skipped не считаются', () => {
|
||
const p = pipelineMetrics([
|
||
run({ display_title: 'process #600 · S7-code-review · x', updated_at: T(1) }),
|
||
run({ display_title: 'process #600 · S7-code-review · x', updated_at: T(2) }),
|
||
run({ display_title: 'process #601 · S7-code-review · x', conclusion: 'skipped', updated_at: T(2) }),
|
||
run({ display_title: 'process #602 · S4-spec-review · x', run_started_at: T(0), updated_at: T(0.5) }),
|
||
]);
|
||
assert.deepEqual(p['S7-code-review'], { runs: 2, issues: 1, wallMinutes: 180, perRunMinutes: 90 });
|
||
assert.deepEqual(p['S4-spec-review'], { runs: 1, issues: 1, wallMinutes: 30, perRunMinutes: 30 });
|
||
});
|
||
|
||
test('#637 jobMinutes: доля «Мутанты» по именам job', () => {
|
||
const jobs = new Map([[1, [
|
||
{ name: 'Мутанты по диффу (1/6): x', started_at: T(0), completed_at: T(1) },
|
||
{ name: 'Фронтенд: типы', started_at: T(0), completed_at: T(0.5) },
|
||
{ name: 'сломан', started_at: 'x', completed_at: T(1) },
|
||
]]]);
|
||
assert.deepEqual(jobMinutes(jobs), { totalMinutes: 90, mutantMinutes: 60, mutantShare: 67 });
|
||
assert.deepEqual(jobMinutes(new Map()), { totalMinutes: 0, mutantMinutes: 0, mutantShare: null });
|
||
});
|
||
|
||
test('#637 buildReport + renderMarkdown: сводка воспроизводит цифры аудита на фикстуре', () => {
|
||
// Форма аудита 22.09: 30 issue с код-ревью — 15 r1, 13 r2, 2 r3 → 1.57; Validate 226 прогонов.
|
||
const issues = [];
|
||
const timelines = new Map();
|
||
const reviewFiles = [];
|
||
for (let n = 1; n <= 30; n++) {
|
||
issues.push({ number: n, title: `t${n}`, closed_at: T(48), state_reason: 'completed' });
|
||
timelines.set(n, [labeled('S1-new', 0), labeled('S7-code-review', 24), labeled('S8-merged', 30)]);
|
||
const rounds = n <= 15 ? 1 : n <= 28 ? 2 : 3;
|
||
for (let r = 1; r <= rounds; r++) reviewFiles.push(`CODE-REVIEW-${n}-r${r}.md`);
|
||
}
|
||
issues.push({ number: 99, title: 'dropped', closed_at: T(48), state_reason: 'not_planned' });
|
||
const runs = Array.from({ length: 226 }, (_, k) => run({ conclusion: k < 178 ? 'success' : k < 215 ? 'failure' : 'cancelled' }));
|
||
const report = buildReport({ since: T(0), until: T(48), issues, timelines, reviewFiles, runs });
|
||
assert.equal(report.issues.completed, 30);
|
||
assert.equal(report.issues.notPlanned, 1);
|
||
assert.equal(Math.round(report.issues.codeReviewRounds.mean * 100) / 100, 1.57);
|
||
assert.deepEqual(report.issues.codeReviewRounds.distribution, { 1: 15, 2: 13, 3: 2 });
|
||
assert.equal(report.issues.medianLeadToS7Hours, 24);
|
||
assert.equal(report.issues.medianReviewToMergeHours, 6);
|
||
const validate = report.runs.find((r) => r.workflow === 'Проверка (CI)');
|
||
assert.deepEqual([validate.runs, validate.success, validate.failure, validate.cancelled], [226, 178, 37, 11]);
|
||
const md = renderMarkdown(report);
|
||
assert.match(md, /Закрыто issue: \*\*31\*\* \(completed 30, not_planned 1\)/);
|
||
assert.match(md, /Раундов код-ревью на issue \| 1\.57 \(r1: 15, r2: 13, r3: 2\)/);
|
||
assert.match(md, /\| Проверка \(CI\) \| 226 \| 178 \| 37 \| 11 \| 0 \| 13560 \|/);
|
||
assert.match(md, /\| #99 \| not_planned \| — \| — \| — \/ — \| 0 \|/);
|
||
assert.equal(report.jobs, null, 'без jobs job-минуты не выдумываются');
|
||
});
|
||
|
||
test('#637 buildReport: нулевая медиана не смешивается с отсутствием данных', () => {
|
||
const issue = { number: 637, title: 'fast', closed_at: T(4 / 60), state_reason: 'completed' };
|
||
const timelines = new Map([[637, [
|
||
labeled('S1-new', 0),
|
||
labeled('S4-spec-review', 0),
|
||
labeled('S5-ready', 1 / 60),
|
||
labeled('S7-code-review', 2 / 60),
|
||
labeled('S8-merged', 3 / 60),
|
||
]] ]);
|
||
const report = buildReport({ since: T(0), until: T(1), issues: [issue], timelines });
|
||
assert.deepEqual([
|
||
report.issues.medianLeadToS7Hours,
|
||
report.issues.medianReviewToMergeHours,
|
||
report.issues.medianLeadToS8Hours,
|
||
report.issues.medianSpecLeadHours,
|
||
], [0, 0, 0.1, 0]);
|
||
assert.match(renderMarkdown(report), /Медиана вход → S7 \| 0 ч/);
|
||
|
||
const empty = buildReport({ since: T(0), until: T(1) });
|
||
assert.equal(empty.issues.medianLeadToS7Hours, null);
|
||
assert.match(renderMarkdown(empty), /Медиана вход → S7 \| —/);
|
||
});
|
||
|
||
test('#637 workflow: еженедельный запуск читает только, публикует summary и artifact', () => {
|
||
const wf = readFileSync(new URL('../.github/workflows/_process-metrics.yml', import.meta.url), 'utf8');
|
||
// #623: расписание — у тонкого вызывающего файла, тело — в `_process-metrics.yml`.
|
||
const caller = readFileSync(new URL('../.github/workflows/process-metrics.yml', import.meta.url), 'utf8');
|
||
assert.match(caller, /schedule:\n(?:\s+#[^\n]*\n)*\s+- cron: '/);
|
||
assert.match(caller, /workflow_dispatch:/);
|
||
assert.ok(!/issues: write/.test(caller), 'потолок прав вызывающего тоже без записи в issue');
|
||
assert.match(wf, /permissions:\n\s+contents: read\n\s+actions: read\n\s+issues: read/);
|
||
assert.match(wf, /node scripts\/process-metrics\.mjs[\s\S]*--output=artifacts\/process-metrics\/report\.md/);
|
||
assert.match(wf, /GITHUB_STEP_SUMMARY/);
|
||
assert.ok(!/issues: write/.test(wf), 'метрики ничего не пишут в issue');
|
||
});
|
||
|
||
test('#682 reviewDocNames: живой каталог и архив legacy/reviews/<тег>/ считаются вместе', () => {
|
||
const listing = [
|
||
'docs/reviews/CODE-REVIEW-600-r2.md',
|
||
'docs/reviews/INDEX.md',
|
||
'legacy/reviews/v1.77.0/CODE-REVIEW-600-r1.md',
|
||
'legacy/reviews/v1.77.0/SPEC-REVIEW-601-r1.md',
|
||
'legacy/docs/ROADMAP.md',
|
||
'',
|
||
].join('\n');
|
||
const names = reviewDocNames(listing);
|
||
assert.deepEqual(names, ['CODE-REVIEW-600-r2.md', 'INDEX.md', 'CODE-REVIEW-600-r1.md', 'SPEC-REVIEW-601-r1.md']);
|
||
// Перенос r1 в архив не уменьшает число раундов задачи.
|
||
assert.equal(reviewRounds(names).get('CODE:600'), 2);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// #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 в коммитах задачи; коммит кандидата беты не в счёт (#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: '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 });
|
||
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), 'push-refused', '#752: отказ push по праву на workflow на ребейзе (#705) — признак merge-candidate.mjs');
|
||
assert.equal(sig('Текст.\n\n<!-- hp:route reclassify criterion=undocumented -->'), 'reclassify');
|
||
assert.equal(sig('Текст.\n\n<!-- hp:route owner-question criterion=undocumented -->'), '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<!-- hp:route owner-question criterion=undocumented -->', 13.5),
|
||
labeled('blocked', 13.5), labeled('S6-in-progress', 13.5), unlabeled('blocked', 13.8),
|
||
labeled('S7-code-review', 14), commented('Трек повышен.\n<!-- hp:route reclassify criterion=undocumented -->', 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', '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',
|
||
], 'возврат — по треку своего момента; 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.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 });
|
||
});
|
||
|
||
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.deepEqual(report.tokens, { docs: 0, totals: null, missing: 0 });
|
||
const md = renderMarkdown(report);
|
||
assert.ok(md.includes(TOKENS_NO_DATA));
|
||
assert.equal(TOKENS_NO_DATA, 'Токены: нет данных (ни один документ ревью не несёт расход модели)', '#737: конвейер строку пишет');
|
||
const section = md.slice(md.indexOf('### Токены'), md.indexOf('###', md.indexOf('### Токены') + 3));
|
||
assert.doesNotMatch(section, /\d/, 'ни оценок, ни пересчётов из минут');
|
||
// #737: строка вне машинного блока больше не читается — это намеренно.
|
||
const recorded = tokenUsage([{ path: 'docs/reviews/CODE-REVIEW-701-r1.md', text: '<!-- hp:usage input_tokens=1200 output_tokens=300 -->' }]);
|
||
assert.deepEqual(recorded, { docs: 0, totals: null, missing: 0 }, 'строка без машинного блока — не данные');
|
||
});
|
||
|
||
// #737 К6: расход — только из машинного блока документа (после маркера блока
|
||
// якорей или последнего маркера SHIP-REVIEW). «Нет данных» — не ноль.
|
||
test('#737 AC6 tokenUsage: только строка машинного блока; суммы по пяти ключам; «нет данных» — не ноль', () => {
|
||
const usage = (input, output, creation, read, turns) => formatUsage({
|
||
input_tokens: input, output_tokens: output, cache_creation_input_tokens: creation, cache_read_input_tokens: read, num_turns: turns,
|
||
});
|
||
const r1 = usage(1000, 200, 30, 4000, 5);
|
||
const r2 = usage(2000, 300, 40, 5000, 6);
|
||
const night = usage(100, 10, 1, 1000, 2);
|
||
// r2 цитирует документ r1 в прозе — расход r1 не засчитывается второй раз.
|
||
const code = { path: 'docs/reviews/CODE-REVIEW-701-r2.md',
|
||
text: withMaterialAnchors(`# CODE-REVIEW-701-r2\n\nУнаследовано из r1:\n\n${r1}\n`, { tree: 'a'.repeat(40), verdict: 'green', high: 0, usage: r2 }) };
|
||
const ship = { path: 'docs/reviews/SHIP-REVIEW-v1.0.0-dev-0123456789ab.md',
|
||
text: `# Ночное ревью\nИтог: High 0 · Medium 0 · Low 0\n${r1}\n\n${anchorBlock({ tag: 'nightly', candidate: 'c'.repeat(40), issues: [701], usage: night })}` };
|
||
assert.deepEqual(tokenUsage([code]), { docs: 1, totals: { input_tokens: 2000, output_tokens: 300, cache_creation_input_tokens: 40, cache_read_input_tokens: 5000, num_turns: 6 }, missing: 0 },
|
||
'считается только строка блока, проза до маркера — нет');
|
||
const both = tokenUsage([code, ship]);
|
||
assert.deepEqual(both, { docs: 2, totals: { input_tokens: 2100, output_tokens: 310, cache_creation_input_tokens: 41, cache_read_input_tokens: 6000, num_turns: 8 }, missing: 0 });
|
||
const tokensSection = (report) => {
|
||
const md = renderMarkdown(report);
|
||
const at = md.indexOf('### Токены');
|
||
return md.slice(at, md.indexOf('###', at + 3));
|
||
};
|
||
const withData = tokensSection(buildReport({ since: T(0), until: T(48), reviewDocs: [code, ship] }));
|
||
assert.match(withData, /^Токены по 2 документам ревью: input_tokens 2100 · output_tokens 310 · cache_creation_input_tokens 41 · cache_read_input_tokens 6000 · num_turns 8\.$/m);
|
||
assert.doesNotMatch(withData, /Без данных о расходе/);
|
||
|
||
// hp:usage-none — документ без данных: в суммы не входит, ноль не печатается.
|
||
const none = { path: 'legacy/reviews/v1.0/SPEC-REVIEW-702-r1.md',
|
||
text: withMaterialAnchors('# SPEC-REVIEW-702-r1\n', { tree: 'b'.repeat(40), verdict: 'green', high: 0, usage: '' }) };
|
||
assert.deepEqual(tokenUsage([code, ship, none]), { ...both, missing: 1 });
|
||
const mixed = tokensSection(buildReport({ since: T(0), until: T(48), reviewDocs: [code, ship, none] }));
|
||
assert.match(mixed, /^Токены по 2 документам ревью: input_tokens 2100 [^\n]*\n\nБез данных о расходе: 1\.$/m);
|
||
assert.deepEqual(tokenUsage([none]), { docs: 0, totals: null, missing: 1 });
|
||
const onlyNone = tokensSection(buildReport({ since: T(0), until: T(48), reviewDocs: [none] }));
|
||
assert.ok(onlyNone.includes(`${TOKENS_NO_DATA}.`));
|
||
assert.match(onlyNone, /Без данных о расходе: 1\./);
|
||
assert.doesNotMatch(onlyNone, /input_tokens|\b0\b/, '«нет данных» не печатается нулём');
|
||
|
||
// Строк нет вовсе (документы до #737, строка только в прозе) — «нет данных», цифр нет.
|
||
const old = { path: 'docs/reviews/CODE-REVIEW-700-r1.md', text: `# r1\n${r1}\n` };
|
||
const empty = tokensSection(buildReport({ since: T(0), until: T(48), reviewDocs: [old] }));
|
||
assert.ok(empty.includes(`${TOKENS_NO_DATA}.`));
|
||
assert.doesNotMatch(empty, /\d/);
|
||
});
|
||
|
||
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 = ['</details>', '### По трекам', '### 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);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// #761: токены — только за окно отчёта, по коммиту, добавившему документ в dev.
|
||
|
||
test('#761 токены за окно: документ вне окна, перенос в архив в окне и hp:usage-none вне окна не считаются (AC1–AC3)', (t) => {
|
||
const dir = mkdtempSync(join(tmpdir(), 'hp-761-'));
|
||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||
const git = (args, env = {}) => {
|
||
const r = spawnSync('git', args, { cwd: dir, encoding: 'utf8', env: { ...process.env, ...env } });
|
||
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 usage = (input, output) => formatUsage({ input_tokens: input, output_tokens: output, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, num_turns: 1 });
|
||
const doc = (title, line) => withMaterialAnchors(`# ${title}\n\nВердикт: зелёный.\n`, { tree: 'a'.repeat(40), verdict: 'green', high: 0, usage: line });
|
||
const commitAt = (iso, message, files) => {
|
||
for (const [path, text] of Object.entries(files)) {
|
||
mkdirSync(join(dir, dirname(path)), { recursive: true });
|
||
writeFileSync(join(dir, path), text);
|
||
}
|
||
git(['add', '-A']);
|
||
git(['commit', '-q', '-m', message], { GIT_AUTHOR_DATE: iso, GIT_COMMITTER_DATE: iso });
|
||
};
|
||
const since = '2026-09-24T00:00:00Z';
|
||
const until = '2026-10-01T00:00:00Z';
|
||
// Вне окна: документ с расходом и документ без данных (hp:usage-none).
|
||
commitAt('2026-09-10T10:00:00Z', 'docs: review document for #700', {
|
||
'docs/reviews/CODE-REVIEW-700-r1.md': doc('CODE-REVIEW-700-r1', usage(1000, 100)),
|
||
'docs/reviews/SPEC-REVIEW-701-r1.md': doc('SPEC-REVIEW-701-r1', ''),
|
||
});
|
||
// Внутри окна: документ с расходом и документ без данных.
|
||
commitAt('2026-09-26T10:00:00Z', 'docs: review document for #702', {
|
||
'docs/reviews/CODE-REVIEW-702-r1.md': doc('CODE-REVIEW-702-r1', usage(20, 2)),
|
||
'docs/reviews/SPEC-REVIEW-703-r1.md': doc('SPEC-REVIEW-703-r1', ''),
|
||
});
|
||
// Внутри окна: перенос документов вне окна в архив (#682) — переименование, не добавление.
|
||
mkdirSync(join(dir, 'legacy/reviews/v1.0.0'), { recursive: true });
|
||
git(['mv', 'docs/reviews/CODE-REVIEW-700-r1.md', 'legacy/reviews/v1.0.0/CODE-REVIEW-700-r1.md']);
|
||
git(['mv', 'docs/reviews/SPEC-REVIEW-701-r1.md', 'legacy/reviews/v1.0.0/SPEC-REVIEW-701-r1.md']);
|
||
commitAt('2026-09-28T10:00:00Z', 'chore(legacy): archive the v1.0.0 review documents', {});
|
||
// После окна: документ с расходом (отчёт за прошлую неделю его не видит).
|
||
commitAt('2026-10-02T10:00:00Z', 'docs: review document for #704', { 'docs/reviews/CODE-REVIEW-704-r1.md': doc('CODE-REVIEW-704-r1', usage(5, 5)) });
|
||
|
||
const added = reviewDocAddedAt(git(['log', '-M', '--diff-filter=AR', '--reverse', '--name-status', '--format=%x1e%cI', 'HEAD', '--', 'docs/reviews', 'legacy/reviews']));
|
||
assert.equal(Date.parse(added.get('legacy/reviews/v1.0.0/CODE-REVIEW-700-r1.md')), Date.parse('2026-09-10T10:00:00Z'), 'перенос в архив сохраняет дату добавления');
|
||
assert.equal(added.has('docs/reviews/CODE-REVIEW-700-r1.md'), false, 'прежнего пути в HEAD нет');
|
||
|
||
const gh = (args) => (String(args[1]).includes('/actions/runs?') ? { workflow_runs: [] } : []);
|
||
const snap = fetchSnapshot({ repo: 'o/r', since, until, gh, git: (args) => git(args) });
|
||
assert.equal(snap.reviewDocs.length, 5, 'тексты читаются из HEAD, как раньше');
|
||
const report = buildReport({ since, until, ...snap });
|
||
assert.equal(report.tokens.docs, 1, 'AC1: только документ, добавленный в окне');
|
||
assert.deepEqual([report.tokens.totals.input_tokens, report.tokens.totals.output_tokens], [20, 2]);
|
||
assert.equal(report.tokens.missing, 1, 'AC3: hp:usage-none — по тому же окну');
|
||
const md = renderMarkdown(report);
|
||
const section = md.slice(md.indexOf('### Токены'), md.indexOf('###', md.indexOf('### Токены') + 3));
|
||
assert.match(section, /^Документы ревью, добавленные в `dev` за окно отчёта/m);
|
||
assert.match(section, /^Токены по 1 документам ревью: input_tokens 20 · output_tokens 2 /m);
|
||
assert.match(section, /^Без данных о расходе: 1\.$/m);
|
||
|
||
// Неделя, в которую документ #700 был добавлен, его видит — в архиве он или нет.
|
||
const early = buildReport({ since: '2026-09-07T00:00:00Z', until: '2026-09-14T00:00:00Z', ...snap });
|
||
assert.deepEqual([early.tokens.docs, early.tokens.totals.input_tokens, early.tokens.missing], [1, 1000, 1]);
|
||
// Без карты дат (юнит над готовыми документами) окна нет — как до #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);
|
||
});
|