diff --git a/scripts/test/accept-branch.mjs b/scripts/test/accept-branch.mjs new file mode 100644 index 0000000..6dc935b --- /dev/null +++ b/scripts/test/accept-branch.mjs @@ -0,0 +1,669 @@ +#!/usr/bin/env node +// Приёмка веток исполнителей одной командой (TASK-GROK-3 п.1–2). +// +// node scripts/test/accept-branch.mjs [опции] <ветка> [<ветка>…] +// +// Для основы (--base) и каждой ветки: +// 1. git worktree в $ACCEPT_ROOT (по умолчанию ../krona-staff-acceptance), detached HEAD; +// node_modules — symlink на текущий репозиторий, если package-lock.json совпадает, иначе npm ci; +// 2. оверлей ТЕКУЩЕГО стенда: tests/e2e/** и scripts/test/** из этого checkout +// (tests/integration, src, миграции — версии ветки, не трогаются); +// 3. `npm run check` без переменных БД; +// 4. КАЖДЫЙ tests/integration/*.test.ts отдельно на свежей мигрированной БД PostgreSQL; +// красный — по строке `Test Files` (ловит ошибки beforeAll/afterAll, при которых +// `Tests N passed`), плюс ненулевой exit; +// 5. e2e (scripts/test/run-e2e.mjs, миграции roundtrip) и сравнение с основой по ID сценария; +// 6. попарно `git merge-tree --write-tree` — все ветки между собой и с основой; +// 7. «форматирование без поведения» для веток с `format` в имени и для --format-check <ветка>: +// версия файла из merge-base → prettier (конфиг основы) → сравнение с версией ветки. +// Итог — markdown в stdout (и в --out). Логи — в tests/e2e/evidence/accept/<время>/. +// +// БД: по умолчанию E2E_PG_ADMIN_URL (bootstrap-админ) один раз создаёт роли со случайными +// паролями, дальше только пересоздаёт БД `krona_acc_it`/`krona_acc_e2e` (provision-pg --roles keep). +// Если заданы E2E_MIGRATOR_URL и E2E_APP_URL — роли не создаются и пароли не меняются, +// все прогоны идут последовательно в БД из этих URL со сбросом через откат всех миграций. +// +// Опции: --base (origin/codex/bootstrap-platform-foundation), --format-check +// (повторяемая; ветка проверяется только на формат, без worktree), --skip-check, +// --skip-integration, --skip-e2e, --out <файл.md>. +// Ветка задаётся как `origin/<имя>`, `<имя>` (ищется origin/<имя>, затем локальный ref) или SHA. +import { spawn, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import * as prettier from 'prettier'; + +const repoRoot = path.resolve(import.meta.dirname, '../..'); +const acceptRoot = path.resolve( + process.env.ACCEPT_ROOT ?? path.join(repoRoot, '..', 'krona-staff-acceptance'), +); + +// ---------- аргументы ---------- +const argv = process.argv.slice(2); +const opts = { + base: 'origin/codex/bootstrap-platform-foundation', + formatOnly: [], + skipCheck: false, + skipIntegration: false, + skipE2e: false, + out: null, + branches: [], +}; +for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--base') opts.base = argv[++i]; + else if (arg === '--format-check') opts.formatOnly.push(argv[++i]); + else if (arg === '--skip-check') opts.skipCheck = true; + else if (arg === '--skip-integration') opts.skipIntegration = true; + else if (arg === '--skip-e2e') opts.skipE2e = true; + else if (arg === '--out') opts.out = argv[++i]; + else if (arg === '-h' || arg === '--help') { + console.log(readFileSync(new URL(import.meta.url), 'utf8').split('\nimport ')[0]); + process.exit(0); + } else if (arg.startsWith('--')) { + console.error(`неизвестная опция ${arg}`); + process.exit(2); + } else opts.branches.push(arg); +} +if (opts.branches.length === 0 && opts.formatOnly.length === 0) { + console.error('Usage: node scripts/test/accept-branch.mjs [--base ref] […]'); + process.exit(2); +} + +// ---------- утилиты ---------- +const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); +const logDir = path.join(repoRoot, 'tests/e2e/evidence/accept', stamp); +mkdirSync(logDir, { recursive: true }); +const progress = (msg) => process.stderr.write(`[accept] ${msg}\n`); +const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, ''); + +function git(args, { cwd = repoRoot, allowFail = false } = {}) { + const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + if (r.status !== 0 && !allowFail) { + throw new Error(`git ${args.join(' ')} → exit ${r.status}: ${r.stderr}`); + } + return { code: r.status, out: r.stdout, err: r.stderr }; +} + +function run(command, args, { cwd, env, logFile, timeoutMs = 20 * 60_000 }) { + return new Promise((resolve) => { + const child = spawn(command, args, { cwd, env }); + let out = ''; + const timer = setTimeout(() => { + out += `\n[accept] TIMEOUT ${timeoutMs} ms — процесс убит\n`; + child.kill('SIGKILL'); + }, timeoutMs); + for (const stream of [child.stdout, child.stderr]) { + stream.on('data', (chunk) => { + out += chunk.toString(); + }); + } + child.on('close', (code) => { + clearTimeout(timer); + const clean = stripAnsi(out); + if (logFile) writeFileSync(logFile, `$ ${[command, ...args].join(' ')}\n${redact(clean)}`); + resolve({ code: code ?? 1, out: clean }); + }); + }); +} + +/** Пароли в URL не должны попасть в логи/отчёт. */ +function redact(text) { + return text.replace(/(postgres(?:ql)?:\/\/[^:\s/]+:)[^@\s]+@/g, '$1***@'); +} + +/** Окружение без переменных БД и секретов стенда. */ +function cleanEnv(extra = {}) { + const env = {}; + for (const [key, value] of Object.entries(process.env)) { + if (/^(DATABASE_|E2E_|TEST_DATABASE|POSTGRES_|KRONA_)/.test(key)) continue; + env[key] = value; + } + return { ...env, NO_COLOR: '1', FORCE_COLOR: '0', ...extra }; +} + +function resolveRef(name) { + const candidates = name.startsWith('origin/') ? [name] : [`origin/${name}`, name]; + for (const candidate of candidates) { + const r = git(['rev-parse', '--verify', '--quiet', `${candidate}^{commit}`], { + allowFail: true, + }); + if (r.code === 0) return { name, ref: candidate, sha: r.out.trim() }; + } + throw new Error(`ветка ${name} не найдена (ни origin/${name}, ни локально)`); +} + +const slugOf = (name) => + name + .replace(/^origin\//, '') + .replace(/[^A-Za-z0-9._-]+/g, '-') + .slice(0, 80); +const short = (sha) => sha.slice(0, 7); + +function firstError(text) { + const lines = text.split('\n'); + const patterns = [ + /((?:[A-Za-z]+Error|error): .+)$/, + /(AssertionError.+)$/, + /(Error: .+)$/, + /(FAIL .+)$/, + ]; + for (const pattern of patterns) { + for (const line of lines) { + const m = pattern.exec(line.trim()); + if (m) return m[1].replace(/\|/g, '\\|').slice(0, 180); + } + } + return ''; +} + +// ---------- БД ---------- +const presetMigrator = process.env.E2E_MIGRATOR_URL; +const presetApp = process.env.E2E_APP_URL; +const adminUrl = process.env.E2E_PG_ADMIN_URL; +const dbMode = presetMigrator && presetApp ? 'preset' : 'admin'; +let roleUrls = null; + +function withDatabase(url, database) { + const u = new URL(url); + u.pathname = `/${database}`; + return u.toString(); +} + +async function prepareRoles() { + if (dbMode === 'preset') { + roleUrls = { migrator: presetMigrator, app: presetApp }; + return 'роли из E2E_MIGRATOR_URL/E2E_APP_URL (не пересоздавались)'; + } + if (!adminUrl) { + throw new Error('нужен E2E_PG_ADMIN_URL или пара E2E_MIGRATOR_URL + E2E_APP_URL'); + } + const dir = mkdtempSync(path.join(tmpdir(), 'krona-accept-')); + const envFile = path.join(dir, 'db.env'); + const r = await run( + process.execPath, + ['scripts/test/provision-pg.mjs', '--out', envFile, '--db', 'krona_acc_roles'], + { cwd: repoRoot, env: { ...cleanEnv(), E2E_PG_ADMIN_URL: adminUrl } }, + ); + if (r.code !== 0) throw new Error(`provision: ${r.out}`); + const env = Object.fromEntries( + readFileSync(envFile, 'utf8') + .split('\n') + .filter((l) => l.includes('=')) + .map((l) => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]), + ); + rmSync(dir, { recursive: true, force: true }); + roleUrls = { migrator: env.DATABASE_MIGRATION_URL, app: env.DATABASE_URL }; + return 'роли созданы один раз через E2E_PG_ADMIN_URL (provision-pg), дальше --roles keep'; +} + +/** Чистая БД: в admin-режиме пересоздаётся, в preset — одна и та же (сброс откатом миграций). */ +async function freshDatabase(name) { + if (dbMode === 'preset') return { ...roleUrls, reset: 'roundtrip' }; + const r = await run( + process.execPath, + ['scripts/test/provision-pg.mjs', '--roles', 'keep', '--db', name], + { cwd: repoRoot, env: { ...cleanEnv(), E2E_PG_ADMIN_URL: adminUrl } }, + ); + if (r.code !== 0) throw new Error(`provision --roles keep ${name}: ${r.out}`); + return { + migrator: withDatabase(roleUrls.migrator, name), + app: withDatabase(roleUrls.app, name), + admin: withDatabase(adminUrl, name), + reset: 'up', + }; +} + +// ---------- worktree ---------- +const repoLockHash = createHash('sha256') + .update(readFileSync(path.join(repoRoot, 'package-lock.json'))) + .digest('hex'); + +async function prepareWorktree(target) { + const dir = path.join(acceptRoot, `wt-${slugOf(target.name)}`); + mkdirSync(acceptRoot, { recursive: true }); + const registered = git(['worktree', 'list', '--porcelain']) + .out.split('\n') + .some((l) => l === `worktree ${dir}`); + if (registered) git(['worktree', 'remove', '--force', dir]); + else if (existsSync(dir)) throw new Error(`${dir} существует, но не является worktree`); + git(['worktree', 'add', '--detach', '--force', dir, target.sha]); + const lock = git(['show', `${target.sha}:package-lock.json`]).out; + const lockSame = createHash('sha256').update(lock).digest('hex') === repoLockHash; + if (lockSame) { + symlinkSync(path.join(repoRoot, 'node_modules'), path.join(dir, 'node_modules'), 'dir'); + } else { + progress(`${target.name}: package-lock.json отличается — npm ci`); + const r = await run('npm', ['ci', '--no-audit', '--no-fund'], { + cwd: dir, + env: cleanEnv(), + logFile: path.join(logDir, `${slugOf(target.name)}-npm-ci.txt`), + }); + if (r.code !== 0) throw new Error(`${target.name}: npm ci exit ${r.code}`); + } + // Оверлей стенда: tests/e2e/** (без evidence) и scripts/test/**. + const overlayTouched = git([ + 'diff', + '--name-only', + `${opts.baseTarget.sha}...${target.sha}`, + '--', + 'tests/e2e', + 'scripts/test', + ]).out.trim(); + for (const rel of ['tests/e2e', 'scripts/test']) { + rmSync(path.join(dir, rel), { recursive: true, force: true }); + cpSync(path.join(repoRoot, rel), path.join(dir, rel), { + recursive: true, + filter: (src) => !src.includes(`${path.sep}tests${path.sep}e2e${path.sep}evidence`), + }); + } + return { + dir, + deps: lockSame ? 'symlink' : 'npm ci', + overlayTouched: overlayTouched ? overlayTouched.split('\n') : [], + }; +} + +// ---------- шаги ---------- +async function stepCheck(target, wt) { + progress(`${target.name}: npm run check`); + const r = await run('npm', ['run', 'check'], { + cwd: wt.dir, + env: cleanEnv(), + logFile: path.join(logDir, `${slugOf(target.name)}-check.txt`), + }); + const tests = /Tests\s+(.+)$/m.exec(r.out)?.[1]?.trim() ?? ''; + return { ok: r.code === 0, code: r.code, tests, error: r.code === 0 ? '' : firstError(r.out) }; +} + +async function migrate(wt, db, logFile) { + const r = await run(process.execPath, ['scripts/test/migrate-roundtrip.mjs', db.reset], { + cwd: wt.dir, + env: cleanEnv({ DATABASE_MIGRATION_URL: db.migrator }), + logFile, + }); + return r; +} + +async function stepIntegration(target, wt) { + const dir = path.join(wt.dir, 'tests/integration'); + const files = existsSync(dir) + ? readdirSync(dir) + .filter((f) => f.endsWith('.test.ts')) + .sort() + : []; + const results = []; + for (const file of files) { + progress(`${target.name}: integration ${file}`); + const slug = `${slugOf(target.name)}-it-${file.replace(/\.test\.ts$/, '')}`; + const db = await freshDatabase('krona_acc_it'); + const m = await migrate(wt, db, path.join(logDir, `${slug}-migrate.txt`)); + if (m.code !== 0) { + results.push({ + file, + ok: false, + files: '', + tests: '', + error: `миграции: ${firstError(m.out)}`, + }); + continue; + } + const r = await run( + process.execPath, + [path.join(wt.dir, 'node_modules/vitest/vitest.mjs'), 'run', `tests/integration/${file}`], + { + cwd: wt.dir, + env: cleanEnv({ + DATABASE_TEST_URL: db.app, + DATABASE_TEST_MIGRATION_URL: db.migrator, + DATABASE_MIGRATION_URL: db.migrator, + }), + logFile: path.join(logDir, `${slug}.txt`), + }, + ); + const filesLine = /Test Files\s+(.+)$/m.exec(r.out)?.[1]?.trim() ?? '(нет строки Test Files)'; + const testsLine = /^\s*Tests\s+(.+)$/m.exec(r.out)?.[1]?.trim() ?? ''; + const ok = r.code === 0 && /\bpassed\b/.test(filesLine) && !/failed/.test(filesLine); + results.push({ + file, + ok, + files: filesLine, + tests: testsLine, + error: ok ? '' : firstError(r.out), + }); + } + return results; +} + +function loadE2e(file) { + const map = new Map(); + for (const f of JSON.parse(readFileSync(file, 'utf8')).testResults) { + for (const t of f.assertionResults) { + const id = /^\[([A-Z0-9/-]+)\]/.exec(t.title)?.[1] ?? 'UNTAGGED'; + const e = map.get(id) ?? { pass: 0, fail: 0, msg: '' }; + if (t.status === 'passed') e.pass += 1; + else if (t.status === 'failed') { + e.fail += 1; + e.msg ||= stripAnsi(t.failureMessages?.[0] ?? '') + .split('\n')[0] + .replace(/\s+/g, ' ') + .replace(/\|/g, '\\|') + .slice(0, 160); + } + map.set(id, e); + } + } + return map; +} + +async function stepE2e(target, wt) { + progress(`${target.name}: e2e`); + const db = await freshDatabase('krona_acc_e2e'); + const slug = slugOf(target.name); + const env = cleanEnv({ + E2E_MIGRATOR_URL: db.migrator, + E2E_APP_URL: db.app, + E2E_MIGRATIONS: 'roundtrip', + ...(db.admin ? { E2E_DB_ADMIN_URL: db.admin } : {}), + }); + const r = await run(process.execPath, ['scripts/test/run-e2e.mjs'], { + cwd: wt.dir, + env, + logFile: path.join(logDir, `${slug}-e2e.txt`), + }); + const jsonPath = path.join(wt.dir, 'tests/e2e/evidence/e2e-results.json'); + if (!existsSync(jsonPath)) + return { ok: false, code: r.code, map: null, error: firstError(r.out) }; + cpSync(jsonPath, path.join(logDir, `${slug}-e2e-results.json`)); + const map = loadE2e(jsonPath); + const red = [...map.values()].filter((e) => e.fail > 0).length; + const verdict = /\[run-e2e\] (OK|FAIL)[^\n]*/.exec(r.out)?.[0] ?? ''; + return { ok: r.code === 0, code: r.code, map, red, green: map.size - red, verdict }; +} + +function diffE2e(baseMap, map) { + const color = (e) => (e ? (e.fail ? 'RED' : 'GREEN') : 'нет'); + const turnedGreen = []; + const turnedRed = []; + const other = []; + for (const id of [...new Set([...baseMap.keys(), ...map.keys()])].sort()) { + const a = color(baseMap.get(id)); + const b = color(map.get(id)); + if (a === b) continue; + if (a === 'RED' && b === 'GREEN') turnedGreen.push(id); + else if (a === 'GREEN' && b === 'RED') turnedRed.push(`${id} (${map.get(id).msg})`); + else other.push(`${id}: ${a}→${b}`); + } + return { turnedGreen, turnedRed, other }; +} + +function mergeTree(a, b) { + const r = git(['merge-tree', '--write-tree', '--name-only', '--no-messages', a.sha, b.sha], { + allowFail: true, + }); + if (r.code === 0) return { status: 'чисто', files: [] }; + if (r.code === 1) { + const files = r.out.split('\n').slice(1).filter(Boolean); + return { status: 'КОНФЛИКТ', files }; + } + return { status: `ошибка git (exit ${r.code})`, files: [r.err.trim()] }; +} + +// ---------- форматирование без поведения ---------- +async function prettierConfigFor(file) { + const anchor = path.join(opts.baseWorktree ?? repoRoot, file); + const config = (await prettier.resolveConfig(anchor)) ?? {}; + const info = await prettier.getFileInfo(anchor, { + ignorePath: path.join(opts.baseWorktree ?? repoRoot, '.prettierignore'), + }); + return { config, info }; +} + +async function normalize(content, file) { + const { config, info } = await prettierConfigFor(file); + if (info.ignored || !info.inferredParser) return content; + return prettier.format(content, { ...config, filepath: file }); +} + +function showFile(sha, file) { + const r = git(['show', `${sha}:${file}`], { allowFail: true }); + return r.code === 0 ? r.out : null; +} + +function changedLines(before, after) { + const dir = mkdtempSync(path.join(tmpdir(), 'krona-fmt-')); + writeFileSync(path.join(dir, 'a'), before); + writeFileSync(path.join(dir, 'b'), after); + const r = git(['diff', '--no-index', '-U0', '--no-color', 'a', 'b'], { + cwd: dir, + allowFail: true, + }); + rmSync(dir, { recursive: true, force: true }); + return r.out + .split('\n') + .filter((l) => /^[+-]/.test(l) && !/^(\+\+\+|---) /.test(l)) + .map((l) => `${l[0]}${l.slice(1).trim()}`) + .filter((l) => l.length > 1); +} + +async function formatCheck(target, others) { + const mergeBase = git(['merge-base', opts.baseTarget.sha, target.sha]).out.trim(); + const changes = git(['diff', '--name-status', '--no-renames', mergeBase, target.sha]) + .out.trim() + .split('\n') + .filter(Boolean) + .map((l) => ({ status: l[0], file: l.split('\t')[1] })); + const rows = []; + for (const { status, file } of changes) { + const touchesConfig = /(^|\/)\.prettier(rc|ignore)|prettier\.config\./.test(file); + if (status === 'D') { + rows.push({ file, verdict: 'удалён', lines: 0, same: [] }); + continue; + } + const after = showFile(target.sha, file); + const before = status === 'A' ? null : showFile(mergeBase, file); + if (before === null) { + rows.push({ file, verdict: 'новый файл', lines: 0, same: [] }); + continue; + } + const formatted = await normalize(before, file); + if (formatted === after && !touchesConfig) continue; // чистое форматирование + const lines = changedLines(formatted, after); + const same = []; + for (const other of others) { + const otherContent = showFile(other.sha, file); + if (otherContent === null) continue; + const otherMergeBase = git(['merge-base', opts.baseTarget.sha, other.sha]).out.trim(); + const otherBase = showFile(otherMergeBase, file) ?? ''; + const otherLines = new Set( + changedLines(await normalize(otherBase, file), await normalize(otherContent, file)), + ); + const matched = lines.filter((l) => otherLines.has(l)).length; + if (matched > 0) same.push(`${other.name}: ${matched}/${lines.length}`); + } + rows.push({ + file, + verdict: touchesConfig ? 'меняет конфиг prettier' : 'не совпадает с prettier(база)', + lines: lines.length, + same, + }); + } + return { mergeBase, files: changes.length, rows }; +} + +// ---------- main ---------- +opts.baseTarget = resolveRef(opts.base); +const targets = opts.branches.map(resolveRef); +const formatTargets = [ + ...targets.filter((t) => /format/i.test(t.name)), + ...opts.formatOnly.map(resolveRef), +]; +const harnessHead = git(['rev-parse', '--short', 'HEAD']).out.trim(); +const harnessDirty = git(['status', '--porcelain', '--', 'tests/e2e', 'scripts/test']).out.trim() + ? ' (+ незакоммиченные изменения стенда)' + : ''; + +const needRuns = !(opts.skipCheck && opts.skipIntegration && opts.skipE2e) && targets.length > 0; +let dbNote = 'не использовалась'; +if (needRuns && !(opts.skipIntegration && opts.skipE2e)) dbNote = await prepareRoles(); + +const results = new Map(); +const all = [opts.baseTarget, ...targets]; +for (const target of all) { + const res = { target }; + results.set(target, res); + if (!needRuns && target !== opts.baseTarget) continue; + try { + const wt = await prepareWorktree(target); + res.wt = wt; + if (target === opts.baseTarget) opts.baseWorktree = wt.dir; + if (!needRuns) continue; + if (!opts.skipCheck) res.check = await stepCheck(target, wt); + if (!opts.skipIntegration) res.integration = await stepIntegration(target, wt); + if (!opts.skipE2e) res.e2e = await stepE2e(target, wt); + } catch (error) { + res.fatal = redact(String(error?.message ?? error)).slice(0, 300); + progress(`${target.name}: ${res.fatal}`); + } +} + +const formatResults = []; +for (const target of formatTargets) { + progress(`${target.name}: проверка форматирования`); + const others = targets.filter((t) => t !== target && !/format/i.test(t.name)); + formatResults.push({ target, ...(await formatCheck(target, others)) }); +} + +// ---------- markdown ---------- +const md = []; +const baseRes = results.get(opts.baseTarget); +md.push(`## accept-branch — ${new Date().toISOString().slice(0, 16).replace('T', ' ')} UTC`); +md.push(''); +md.push( + `База: \`${opts.baseTarget.ref}\` = ${short(opts.baseTarget.sha)}; стенд (оверлей tests/e2e, scripts/test): ${harnessHead}${harnessDirty}; БД: ${dbNote}. Логи: \`${path.relative(repoRoot, logDir)}/\`.`, +); +md.push(''); +if (needRuns) { + md.push('### Сводка'); + md.push(''); + md.push( + '| Ветка | SHA | npm run check | integration (красные файлы) | e2e red/green | e2e: стало зелёным | e2e: стало красным |', + ); + md.push('|---|---|---|---|---|---|---|'); + for (const res of results.values()) { + const t = res.target; + const isBase = t === opts.baseTarget; + const name = isBase ? `${t.ref} (база)` : t.ref; + if (res.fatal) { + md.push(`| ${name} | ${short(t.sha)} | ОШИБКА: ${res.fatal} | | | | |`); + continue; + } + const check = res.check + ? res.check.ok + ? `OK (${res.check.tests})` + : `FAIL exit=${res.check.code}: ${res.check.error}` + : 'не запускался'; + const reds = res.integration?.filter((r) => !r.ok).map((r) => r.file) ?? []; + const integration = res.integration + ? `${res.integration.length - reds.length}/${res.integration.length} зелёных${reds.length ? `; FAIL: ${reds.join(', ')}` : ''}` + : 'не запускались'; + let e2e = 'не запускался'; + let green = ''; + let red = ''; + if (res.e2e?.map) { + e2e = `${res.e2e.red}/${res.e2e.green}`; + if (!isBase && baseRes.e2e?.map) { + const d = diffE2e(baseRes.e2e.map, res.e2e.map); + green = d.turnedGreen.join(', ') || '—'; + red = [...d.turnedRed, ...d.other].join('; ') || '—'; + } + } else if (res.e2e) e2e = `стенд не поднялся: ${res.e2e.error}`; + if (isBase) { + green = 'n/a'; + red = 'n/a'; + } + md.push( + `| ${name} | ${short(t.sha)} | ${check} | ${integration} | ${e2e} | ${green} | ${red} |`, + ); + } + md.push(''); + if ( + results.values().next().value.integration || + targets.some((t) => results.get(t).integration) + ) { + md.push('### Интеграционные тесты (каждый файл отдельно, свежая БД)'); + md.push(''); + md.push('| Ветка | Файл | Итог | Test Files | Tests | Первая ошибка |'); + md.push('|---|---|---|---|---|---|'); + for (const res of results.values()) { + for (const r of res.integration ?? []) { + md.push( + `| ${res.target.ref} | ${r.file} | ${r.ok ? 'OK' : 'FAIL'} | ${r.files} | ${r.tests} | ${r.error} |`, + ); + } + } + md.push(''); + } + const overlayNotes = [...results.values()].filter((r) => r.wt?.overlayTouched.length); + if (overlayNotes.length) { + md.push('Внимание: ветка меняла файлы стенда, при приёмке они заменены оверлеем:'); + for (const r of overlayNotes) md.push(`- ${r.target.ref}: ${r.wt.overlayTouched.join(', ')}`); + md.push(''); + } + const npmCi = [...results.values()].filter((r) => r.wt?.deps === 'npm ci'); + if (npmCi.length) + md.push(`npm ci (lock отличается): ${npmCi.map((r) => r.target.ref).join(', ')}\n`); +} + +if (targets.length > 0) { + md.push('### Попарные слияния (`git merge-tree --write-tree`)'); + md.push(''); + md.push('| Ветка A | Ветка B | Результат | Конфликтные файлы |'); + md.push('|---|---|---|---|'); + const pairs = []; + for (const t of targets) pairs.push([opts.baseTarget, t]); + for (let i = 0; i < targets.length; i += 1) { + for (let j = i + 1; j < targets.length; j += 1) pairs.push([targets[i], targets[j]]); + } + for (const [a, b] of pairs) { + const r = mergeTree(a, b); + md.push(`| ${a.ref} | ${b.ref} | ${r.status} | ${r.files.join(', ')} |`); + } + md.push(''); +} + +if (formatResults.length) { + md.push('### Форматирование без поведения'); + md.push(''); + for (const f of formatResults) { + const verdict = f.rows.length === 0 ? 'ТОЛЬКО ФОРМАТИРОВАНИЕ' : 'ЕСТЬ ИЗМЕНЕНИЯ СВЕРХ PRETTIER'; + md.push( + `**${f.target.ref}** (${short(f.target.sha)}), merge-base ${short(f.mergeBase)}, изменено файлов: ${f.files} — ${verdict}`, + ); + md.push(''); + if (f.rows.length) { + md.push('| Файл | Почему | Строк сверх prettier | Эти же строки в ветке |'); + md.push('|---|---|---|---|'); + for (const r of f.rows) { + md.push(`| ${r.file} | ${r.verdict} | ${r.lines} | ${r.same.join('; ') || '—'} |`); + } + md.push(''); + } + } +} + +const text = redact(md.join('\n')); +writeFileSync(path.join(logDir, 'summary.md'), text); +if (opts.out) writeFileSync(opts.out, text); +console.log(text);