Files
houseplan-card/test/ship-review.test.mjs
Claudeandclaude[bot] e58d7d06f8 feat(process): nightly ship batch review, reused by the beta gate by patch set (#727)
Ship tasks merge without a model review and their code was first read by
the batch review right before a beta: one session over the whole range,
ten to forty-five minutes on the release path, days after the merge. The
gate also knew a single document (SHIP-REVIEW-<tag>.md) and covered tasks
by number only, so a commit that landed after the review under the same
trailer still counted as read.

- scripts/ship-review.mjs: the patch set of a task is the sorted
  `git patch-id --stable` of its range commits, without `Release:`
  commits (the beta candidate carries every Issue: of the line) and
  commits touching only docs/reviews/**; the diff options are explicit
  so a local git config cannot change it. shipCoverage rates every ship
  task from the documents of the same base (candidate and origin/dev,
  latest publication wins): clean, high, stale, none; documents without
  `patches` cover by number. `tag=nightly` is a reserved mode: the
  candidate is required, the document is
  SHIP-REVIEW-<base>-dev-<sha12>.md, only none/stale tasks are read and
  nothing runs when nothing is uncovered. The beta reads the same delta
  (force=true reads everything, as before); the brief names what the
  night already read. The gate refuses none/stale with the command and
  keeps the High refusal with force=true; all clean passes without a tag
  document. The machine block gains `mode` and `patches` at its end.
  comment-high writes one line per task of a nightly document with High,
  once per document (hp:ship-review-high).
- _ship-review.yml: prepare refuses nightly without a candidate before
  defaulting to the dev tip, computes the document from base and SHA and
  no longer reads a prepare failure behind `| tee` as "no ship tasks";
  publish takes mode and patches from prepare, never from the model
  result; a new step comments High at night with HP_PROCESS_TOKEN.
- _nightly.yml: the Validate run SHA is a separate step output before
  the wait; a new job dispatches ship-review.yml -f tag=nightly on it
  whatever Validate's outcome, waits only for the run to appear and
  never colours the night. Thin files in main are unchanged.
- reviews-index/reviews-archive: the nightly name is a ship document
  with nightly: true; a beta base archives with its line, a stable base
  with the nearest archived line newer than the base, or stays.
- PROCESS.md §11.7, §10.4 and REVIEWER.md describe the nightly mode,
  patch set, coverage and beta delta; the digest test pins the key rule.

Tests run the prepare, publish and comment steps and the nightly steps
on real bash with real git in temporary repositories; only push
transport and gh are faked.

Issue: #727
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 03:25:24 +00:00

715 lines
53 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// #696, PROCESS.md §11.7: задачи track:ship сливаются без ревью модели; их код
// читает пакетное ревью диапазона перед бетой, и гейт беты требует документ.
import assert from 'node:assert/strict';
import test from 'node:test';
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 { fileURLToPath } from 'node:url';
import {
SHIP_MERGE_MARKER_RE, anchorBlock, commitPatchIds, countsForPatchSet, formatPatches, highCommentBody, highCommentTargets,
isShipIssue, issuePatchSets, nightlyDocPath, parseAnchorBlock, parsePatches, planShipReview, renderShipBrief, reviewSubject,
shipCoverage, shipDocPath, shipIssuesInRange, shipReviewDocPath, shipReviewMode, shipReviewProblems, shipRiskFrom, specSection,
} from '../scripts/ship-review.mjs';
import { parseDocName, renderIndex } from '../scripts/reviews-index.mjs';
import { archivePlan } from '../scripts/reviews-archive.mjs';
const sha = (c) => c.repeat(40);
const MARKER = `<!-- hp:ship-merge material=${sha('a')} -->`;
test('#696 документ — по тегу беты или стабильному, имя фиксировано', () => {
assert.equal(shipReviewDocPath('v1.79.0-beta.1'), 'docs/reviews/SHIP-REVIEW-v1.79.0-beta.1.md');
assert.equal(shipReviewDocPath('v1.79.0'), 'docs/reviews/SHIP-REVIEW-v1.79.0.md');
for (const bad of ['1.79.0', 'v1.79', 'v1.79.0-rc.1', '', 'v1.79.0/../x']) {
assert.throws(() => shipReviewDocPath(bad), /not a release tag/, bad);
}
});
test('#696 ship-задача — по маркеру конвейера или метке track:ship', () => {
assert.equal(isShipIssue({ labels: [], comments: [{ body: `Слияние без ревью модели\n\n${MARKER}` }] }), true);
assert.equal(isShipIssue({ labels: [{ name: 'track:ship' }], comments: [] }), true);
assert.equal(isShipIssue({ labels: ['track:show'], comments: [{ body: 'hp:ship-merge без маркера' }] }), false);
assert.equal(isShipIssue({}), false);
});
test('#696 состав — трейлеры диапазона; show и ask в пакет не входят', () => {
const commits = [
{ sha: sha('c'), message: 'fix: text\n\nIssue: #701\nUser-Visible: yes' },
{ sha: sha('b'), message: 'feat: y\n\nIssue: #702\nUser-Visible: yes' },
{ sha: sha('a'), message: 'fix: css\n\nIssue: #701\nUser-Visible: yes' },
{ sha: sha('d'), message: 'chore: упоминание #703 — не трейлер' },
];
const data = {
701: { title: 'Опечатка', body: 'Отчёт\n\n## ТЗ\n\nЗаменить «Сохранть» на «Сохранить»; видно в диалоге.\n\n## Прочее\nх', labels: [], comments: [{ body: MARKER }] },
702: { title: 'Фича', body: '', labels: [{ name: 'track:show' }], comments: [] },
};
const ship = shipIssuesInRange({ commits, issueData: (n) => data[n] ?? null });
assert.deepEqual(ship.map((i) => i.number), [701]);
assert.deepEqual(ship[0].commits.map((c) => c.sha), [sha('a'), sha('c')], 'коммиты по порядку истории');
assert.equal(ship[0].spec, '## ТЗ\n\nЗаменить «Сохранть» на «Сохранить»; видно в диалоге.');
const brief = renderShipBrief({ tag: 'v1.79.0-beta.1', candidate: sha('e'), base: { tag: 'v1.78.0', sha: sha('f') }, ship });
assert.match(brief, /### #701 · Опечатка/);
assert.match(brief, /SHIP-REVIEW-v1\.79\.0-beta\.1\.md/);
assert.match(brief, new RegExp(sha('a')));
assert.equal(specSection('без раздела'), '');
});
test('#696 гейт: без ship-задач документ не нужен, без документа — отказ с командой', () => {
assert.deepEqual(shipReviewProblems({ tag: 'v1.79.0-beta.1', ship: [], docText: null }), []);
const [problem] = shipReviewProblems({ tag: 'v1.79.0-beta.1', ship: [{ number: 701 }], docText: null });
assert.match(problem, /#701/);
assert.match(problem, /gh workflow run ship-review\.yml --ref dev -f tag=v1\.79\.0-beta\.1/);
});
test('#696 гейт: машинный блок покрывает все задачи и не несёт High', () => {
const tag = 'v1.79.0-beta.1';
const doc = (fields) => `# Ревью\nИтог: …\n\n${anchorBlock({ tag, candidate: sha('e'), issues: [701, 704], ...fields })}`;
assert.deepEqual(parseAnchorBlock(doc({ high: 0, medium: 1, low: 2 })), {
tag, candidate: sha('e'), issues: [701, 704], high: 0, medium: 1, low: 2,
});
assert.deepEqual(shipReviewProblems({ tag, ship: [{ number: 701 }, { number: 704 }], docText: doc({ high: 0 }) }), []);
const partial = shipReviewProblems({ tag, ship: [{ number: 701 }, { number: 709 }], docText: doc({ high: 0 }) });
assert.equal(partial.length, 1);
assert.match(partial[0], /не покрывает ship-задачи #709/);
const high = shipReviewProblems({ tag, ship: [{ number: 701 }], docText: doc({ high: 2 }) });
assert.equal(high.length, 1);
assert.match(high[0], /High 2/);
assert.match(shipReviewProblems({ tag, ship: [{ number: 701 }], docText: '# без блока' })[0], /без машинного блока/);
assert.match(shipReviewProblems({ tag: 'v1.79.0-beta.2', ship: [{ number: 701 }], docText: doc({ high: 0 }) })[0], /для тега v1\.79\.0-beta\.1/);
});
test('#696 индекс и архив знают SHIP-REVIEW: бета в индексе, архив — каталог стабильной линии', () => {
assert.deepEqual(parseDocName('SHIP-REVIEW-v1.79.0-beta.1.md'), { stage: 'ship', issue: null, round: null, suffix: null, tag: 'v1.79.0-beta.1' });
const md = renderIndex({ entries: [
{ name: 'SHIP-REVIEW-v1.79.0-beta.1.md', stage: 'ship', issue: null, tag: 'v1.79.0-beta.1', verdict: '—', high: 0, medium: 1, findings: [], files: [] },
{ name: 'SHIP-REVIEW-v1.79.0-beta.2.md', stage: 'ship', issue: null, tag: 'v1.79.0-beta.2', verdict: '—', high: 0, medium: 0, findings: [], files: [] },
{ name: 'RELEASE-REVIEW-v1.79.0.md', stage: 'release', issue: null, tag: 'v1.79.0', verdict: '—', high: 1, medium: 0, findings: [], files: [] },
] });
const rows = md.split('\n').filter((line) => line.startsWith('| бета') || line.startsWith('| линия'));
assert.deepEqual(rows.map((r) => r.split('|')[1].trim()), ['линия v1.79.0', 'бета v1.79.0-beta.2', 'бета v1.79.0-beta.1']);
assert.match(rows[1], /пакетное ревью ship · —/);
const plan = archivePlan({
names: ['SHIP-REVIEW-v1.79.0-beta.1.md', 'SHIP-REVIEW-v1.80.0-beta.1.md'],
lines: [{ tag: 'v1.79.0', issues: [] }], open: [], through: 'v1.79.0',
});
assert.deepEqual(plan.moves.map((m) => m.to), ['legacy/reviews/v1.79.0/SHIP-REVIEW-v1.79.0-beta.1.md']);
assert.ok(plan.kept.some((k) => k.name === 'SHIP-REVIEW-v1.80.0-beta.1.md'));
});
test('#696 _ship-review.yml: модель без права записи, документ с машинным блоком в dev', () => {
// #716: тело из dev; тонкий `ship-review.yml` в main — кнопка и потолок прав.
const workflow = readFileSync(fileURLToPath(new URL('../.github/workflows/_ship-review.yml', import.meta.url)), 'utf8');
const model = workflow.slice(workflow.indexOf('\n model_review:'), workflow.indexOf('\n publish:'));
assert.match(model, /permissions:\n\s+contents: read\n\s+steps:/, 'модель только читает');
assert.match(model, /github_token: \$\{\{ secrets\.GITHUB_TOKEN \}\}/, 'без обмена OIDC на App-токен (#556)');
const publish = workflow.slice(workflow.indexOf('\n publish:'));
assert.match(publish, /m\.anchorBlock\(/, 'машинный блок пишет публикация, не модель');
assert.match(publish, /reviews-index\.mjs --dir=docs\/reviews --strict/);
assert.match(workflow, /if: needs\.prepare\.outputs\.proceed == 'true'/);
assert.match(workflow, /ship-задач в диапазоне нет — ревью не нужно/);
});
test('#696 оба пути публикации беты проверяют пакетное ревью ship до выпуска', () => {
const workflow = readFileSync(fileURLToPath(new URL('../.github/workflows/publish-prerelease.yml', import.meta.url)), 'utf8');
const gate = workflow.slice(workflow.indexOf('\n gate:'), workflow.indexOf('\n publish:'));
assert.match(gate, /node scripts\/ship-review\.mjs check --tag="\$TAG" --candidate="\$SHA"/, 'гейт CI-публикации');
const local = readFileSync(fileURLToPath(new URL('../scripts/release-prerelease.mjs', import.meta.url)), 'utf8');
const main = local.slice(local.indexOf('const main = async'));
const check = main.indexOf("'scripts/ship-review.mjs', 'check'");
assert.ok(check > 0, 'локальная публикация зовёт тот же гейт');
assert.ok(check < main.indexOf('if (checkOnly) return;'), 'и в режиме --check тоже');
});
test('#707 AC12: бриф ship печатает строку риска из комментария hp:ship-merge, если она есть', async () => {
const { classifyRisk, shipRiskText } = await import('../scripts/process-track.mjs');
const risk = classifyRisk("diff --git a/src/pointer-modality.ts b/src/pointer-modality.ts\n--- a/src/pointer-modality.ts\n+++ b/src/pointer-modality.ts\n@@ -2,0 +3 @@\n+ if (e.pointerType === 'touch') return;\n");
const line = shipRiskText({ risk, confirmed: true });
const merge = `**Слияние без ревью модели: трек ship.** …\n\n${line}\n\n${MARKER}\n`;
assert.equal(SHIP_MERGE_MARKER_RE.exec(merge)?.[1], sha('a'), 'маркер слияния находится и с новой строкой');
assert.deepEqual(shipRiskFrom([{ body: 'обсуждение' }, { body: merge }]), { classes: ['touch'], line: line.split('\n')[0] });
assert.equal(shipRiskFrom([{ body: `Слияние без ревью модели\n\n${MARKER}` }]), null, 'комментарий до #707 — риск не записан');
assert.equal(shipRiskFrom([{ body: '<!-- hp:ship-risk classes=touch -->' }]), null, 'строка риска вне комментария слияния не считается');
const commits = [{ sha: sha('c'), message: 'fix: x\n\nIssue: #701\nUser-Visible: no' }, { sha: sha('d'), message: 'fix: y\n\nIssue: #702\nUser-Visible: no' }];
const data = {
701: { title: 'С риском', body: '## ТЗ\n\nстрока', labels: [], comments: [{ body: merge }] },
702: { title: 'Без риска', body: '## ТЗ\n\nстрока', labels: [], comments: [{ body: MARKER }] },
};
const ship = shipIssuesInRange({ commits, issueData: (n) => data[n] });
const brief = renderShipBrief({ tag: 'v1.80.0-beta.1', candidate: sha('e'), base: null, ship });
const section = (n) => brief.split('\n### ').find((part) => part.startsWith(`#${n} `)) ?? '';
assert.match(section(701), /Риск по участкам \(трек подтверждён владельцем, не повышен\): touch: src\/pointer-modality\.ts:3 · участок pointer-modality, токен pointerType/);
assert.doesNotMatch(section(702), /Риск по участкам/);
assert.equal(ship.find((i) => i.number === 702).risk, undefined);
});
// ---------- #727: ночное пакетное ревью ship и переиспользование по патч-набору ----------
const PID = (c) => c.repeat(40);
const NIGHT = (sha12 = '108427dc1234', base = 'v1.79.0-beta.1') => `SHIP-REVIEW-${base}-dev-${sha12}.md`;
const shipDoc = (name, fields) => ({
name,
text: `# Ночное ревью\nИтог: …\n\n${anchorBlock({ tag: 'nightly', candidate: sha('e'), base: 'v1.79.0-beta.1', mode: 'nightly', ...fields })}`,
});
/** Временный git-репозиторий без конфига и хуков пользователя (#496, #633). */
function tempRepo(t) {
const dir = mkdtempSync(join(tmpdir(), 'hp-727-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const env = {
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^GIT_/i.test(key))),
GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t',
GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null',
};
const git = (...args) => {
const r = spawnSync('git', args, { cwd: dir, encoding: 'utf8', env });
assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
};
git('init', '-q', '-b', 'dev');
git('config', 'core.hooksPath', '/dev/null');
const commit = (files, message) => {
for (const [path, text] of Object.entries(files)) {
mkdirSync(dirname(join(dir, path)), { recursive: true });
writeFileSync(join(dir, path), text);
}
git('add', '-A');
git('commit', '-q', '-m', message);
return { sha: git('rev-parse', 'HEAD'), message: `${message}\n` };
};
return { dir, git, commit };
}
test('#727 AC1 К1: патч-набор — patch-id коммитов задачи; Release: и только docs/reviews/** не входят; cherry-pick тот же', (t) => {
const repo = tempRepo(t);
repo.commit({ 'src/a.txt': 'a\n', 'src/b.txt': 'b\n', 'src/c.txt': 'c\n' }, 'base');
const base = repo.git('rev-parse', 'HEAD');
const one = repo.commit({ 'src/a.txt': 'a\nA\n' }, 'fix: a\n\nIssue: #701\nUser-Visible: no');
const two = repo.commit({ 'src/b.txt': 'b\nB\n' }, 'fix: b\n\nIssue: #701\nUser-Visible: no');
const release = repo.commit({ 'src/c.txt': 'c\nversion\n' }, 'Release v1.80.0-beta.1 candidate\n\nIssue: #701\nIssue: #702\nUser-Visible: yes\nRelease: v1.80.0-beta.1');
const docsOnly = repo.commit({ 'docs/reviews/CODE-REVIEW-701-r1.md': '# r1\n', 'docs/reviews/INDEX.md': 'x\n' }, 'docs: review document for #701\n\nIssue: #701\nUser-Visible: no');
const mixed = repo.commit({ 'docs/reviews/INDEX.md': 'y\n', 'src/c.txt': 'c\nversion\nC\n' }, 'fix: c\n\nIssue: #702\nUser-Visible: no');
const commits = [mixed, docsOnly, release, two, one];
const sets = issuePatchSets({ commits, numbers: [701, 702], cwd: repo.dir });
const ids = commitPatchIds([one.sha, two.sha, release.sha, docsOnly.sha, mixed.sha], { cwd: repo.dir });
assert.equal(new Set(ids.values()).size, 5, 'у каждого коммита свой patch-id');
assert.deepEqual(sets.get(701), [ids.get(one.sha), ids.get(two.sha)].sort(), 'два коммита задачи → два patch-id');
assert.ok(!sets.get(701).includes(ids.get(release.sha)), 'Release:-коммит с трейлером задачи не входит');
assert.ok(!sets.get(701).includes(ids.get(docsOnly.sha)), 'коммит только в docs/reviews/** не входит');
assert.deepEqual(sets.get(702), [ids.get(mixed.sha)], 'коммит кода и docs/reviews/** входит');
assert.deepEqual(issuePatchSets({ commits: [...commits].reverse(), numbers: [701, 702], cwd: repo.dir }), sets, 'порядок не влияет');
// Ночь до Release-коммита и бета на нём читают один набор: Release: исключён.
assert.deepEqual(issuePatchSets({ commits: [two, one], numbers: [701], cwd: repo.dir }).get(701), sets.get(701));
// Тот же дифф в другом коммите (cherry-pick в обратном порядке) — тот же patch-id.
repo.git('checkout', '-q', '-b', 'other', base);
repo.git('cherry-pick', two.sha);
const pickedTwo = { sha: repo.git('rev-parse', 'HEAD'), message: two.message };
repo.git('cherry-pick', one.sha);
const pickedOne = { sha: repo.git('rev-parse', 'HEAD'), message: one.message };
assert.notEqual(pickedOne.sha, one.sha);
assert.deepEqual(issuePatchSets({ commits: [pickedOne, pickedTwo], numbers: [701], cwd: repo.dir }).get(701), sets.get(701));
assert.equal(countsForPatchSet({ message: 'x\n\nRelease: v1.80.0', files: ['src/a.ts'] }), false);
assert.equal(countsForPatchSet({ message: 'x', files: ['docs/reviews/INDEX.md'] }), false);
assert.equal(countsForPatchSet({ message: 'x', files: ['docs/reviews/INDEX.md', 'src/a.ts'] }), true);
assert.equal(countsForPatchSet({ message: 'Release v1 candidate (упоминание Release: в теме не трейлер)', files: ['a'] }), true);
});
test('#727 AC2 К2: ночной режим — кандидат обязателен, имя по базе и SHA, блок несёт mode и patches', () => {
assert.throws(() => shipReviewMode({ tag: 'nightly', candidate: '' }), /tag=nightly требует candidate/);
assert.equal(shipReviewMode({ tag: 'nightly', candidate: sha('a') }), 'nightly');
assert.equal(shipReviewMode({ tag: 'v1.79.0-beta.2' }), 'beta');
assert.throws(() => shipReviewMode({ tag: 'v1.79' }), /not a release tag/);
const candidate = `108427dc1234${'f'.repeat(28)}`;
assert.equal(nightlyDocPath({ base: 'v1.79.0-beta.1', candidate }), `docs/reviews/${NIGHT()}`);
assert.equal(shipDocPath({ tag: 'nightly', candidate, base: 'v1.78.0' }), 'docs/reviews/SHIP-REVIEW-v1.78.0-dev-108427dc1234.md');
assert.equal(shipDocPath({ tag: 'v1.79.0-beta.2', candidate, base: 'v1.79.0-beta.1' }), 'docs/reviews/SHIP-REVIEW-v1.79.0-beta.2.md');
assert.throws(() => nightlyDocPath({ base: null, candidate }), /ночной документ без базы/);
// Блок: прежние строки на месте, mode и patches — в конце; parseAnchorBlock их читает.
const patches = new Map([[702, [PID('b')]], [701, [PID('c'), PID('a')]]]);
const block = anchorBlock({ tag: 'nightly', candidate, base: 'v1.79.0-beta.1', issues: [701, 702], high: 0, mode: 'nightly', patches });
assert.match(block, new RegExp(`\`\`\`\\ntag nightly\\ncandidate ${candidate}\\nbase v1\\.79\\.0-beta\\.1\\nissues 701,702\\nhigh 0\\nmedium 0\\nlow 0\\nrun —\\nmode nightly\\npatches 701:${PID('a')}\\+${PID('c')},702:${PID('b')}\\n\`\`\``));
assert.deepEqual(parseAnchorBlock(block), {
tag: 'nightly', candidate, issues: [701, 702], high: 0, medium: 0, low: 0, base: 'v1.79.0-beta.1', mode: 'nightly',
patches: new Map([[701, [PID('a'), PID('c')]], [702, [PID('b')]]]),
});
assert.deepEqual(parsePatches(formatPatches(patches)), new Map([[701, [PID('a'), PID('c')]], [702, [PID('b')]]]));
assert.deepEqual(parsePatches('693:'), new Map([[693, []]]), 'задача без своих коммитов — пустой набор');
assert.deepEqual(parsePatches('701:zz,702:' + PID('b')), new Map([[702, [PID('b')]]]), 'испорченная запись пропускается');
// Все задачи clean → читать нечего: proceed=false, issues пусто.
const ship = [{ number: 701, patches: [PID('a')] }, { number: 702, patches: [PID('b')] }];
const docs = [shipDoc(NIGHT('aaaaaaaaaaaa'), { issues: [701, 702], high: 0, patches: new Map([[701, [PID('a')]], [702, [PID('b')]]]) })];
const plan = planShipReview({ tag: 'nightly', ship, docs, base: 'v1.79.0-beta.1' });
assert.equal(plan.mode, 'nightly');
assert.deepEqual(plan.read, []);
assert.match(plan.note, /^все ship-задачи покрыты: SHIP-REVIEW-v1\.79\.0-beta\.1-dev-aaaaaaaaaaaa\.md/);
assert.equal(reviewSubject('nightly'), 'Ночное пакетное ревью: кандидат — голова `dev` после ночного полного Validate.');
assert.equal(reviewSubject('v1.79.0-beta.2'), 'Бета: v1.79.0-beta.2.');
});
test('#727 AC3 К3: покрытие — четыре статуса, последний документ главнее, чужая база не в счёт, старый документ — по номеру', () => {
const base = 'v1.79.0-beta.1';
const ship = [
{ number: 701, patches: [PID('a')] }, { number: 702, patches: [PID('b')] },
{ number: 703, patches: [PID('c'), PID('d')] }, { number: 704, patches: [PID('e')] },
];
const docs = [
shipDoc(NIGHT('111111111111'), { issues: [701, 703], high: 0, patches: new Map([[701, [PID('a')]], [703, [PID('c')]]]) }),
shipDoc(NIGHT('222222222222'), { issues: [702], high: 1, patches: new Map([[702, [PID('b')]]]) }),
];
const status = (list, opts = {}) => Object.fromEntries(shipCoverage({ ship, docs: list, base, ...opts }).map((c) => [c.number, `${c.status}:${c.doc}`]));
assert.deepEqual(status(docs), {
701: `clean:${NIGHT('111111111111')}`, 702: `high:${NIGHT('222222222222')}`,
703: `stale:${NIGHT('111111111111')}`, 704: 'none:null',
});
// Последний главнее: clean, затем high на том же наборе → high; и наоборот → clean.
const clean = shipDoc(NIGHT('333333333333'), { issues: [701], high: 0, patches: new Map([[701, [PID('a')]]]) });
const high = shipDoc(NIGHT('444444444444'), { issues: [701], high: 2, patches: new Map([[701, [PID('a')]]]) });
assert.equal(status([clean, high])[701], `high:${NIGHT('444444444444')}`, 'позже опубликованный high отменяет ранний clean');
assert.equal(status([high, clean])[701], `clean:${NIGHT('333333333333')}`);
// Документ другой базы не покрывает.
const foreign = { name: 'SHIP-REVIEW-v1.79.0-beta.1.md', text: anchorBlock({ tag: 'v1.79.0-beta.1', candidate: sha('e'), base: 'v1.78.0', issues: [704], high: 0 }) };
assert.equal(status([foreign])[704], 'none:null', 'база v1.78.0 ≠ v1.79.0-beta.1');
// Старый документ без patches (#696) покрывает по номеру — тот же номер, любой код.
const legacy = { name: 'SHIP-REVIEW-v1.79.0-beta.2.md', text: anchorBlock({ tag: 'v1.79.0-beta.2', candidate: sha('e'), base, issues: [704], high: 0 }) };
assert.equal(status([legacy])[704], 'clean:SHIP-REVIEW-v1.79.0-beta.2.md');
// Документ с patches без записи о задаче, число High не записано — не clean.
const partial = shipDoc(NIGHT('555555555555'), { issues: [704], high: 0, patches: new Map() });
assert.equal(status([partial])[704], `stale:${NIGHT('555555555555')}`);
const noHigh = { name: NIGHT('666666666666'), text: shipDoc('x', { issues: [704], patches: new Map([[704, [PID('e')]]]) }).text.replace('\nhigh 0\n', '\nhigh —\n') };
assert.equal(status([noHigh])[704], `high:${NIGHT('666666666666')}`);
});
test('#727 AC4 К4: гейт по покрытию — clean без документа тега проходит; stale, none, high — отказ', () => {
const tag = 'v1.79.0-beta.2';
const base = 'v1.79.0-beta.1';
const ship = [{ number: 701, patches: [PID('a')] }, { number: 702, patches: [PID('b')] }];
const night = shipDoc(NIGHT(), { issues: [701, 702], high: 0, patches: new Map([[701, [PID('a')]], [702, [PID('b')]]]) });
assert.deepEqual(shipReviewProblems({ tag, ship, docs: [night], base }), [], 'всё clean без документа тега');
assert.deepEqual(shipReviewProblems({ tag, ship: [], docs: [], base }), [], 'без ship-задач документ не нужен');
// stale: код задачи изменился после ночного ревью.
const changed = [{ number: 701, patches: [PID('a'), PID('f')] }, ship[1]];
const stale = shipReviewProblems({ tag, ship: changed, docs: [night], base });
assert.equal(stale.length, 1);
assert.match(stale[0], new RegExp(`ship-задача #701 изменилась после ревью docs/reviews/${NIGHT().replace(/\./g, '\\.')}`));
assert.match(stale[0], /gh workflow run ship-review\.yml --ref dev -f tag=v1\.79\.0-beta\.2$/, 'документа тега нет — дельта без force');
// none: задача слита после ночи.
const none = shipReviewProblems({ tag, ship: [...ship, { number: 709, patches: [PID('9')] }], docs: [night], base });
assert.equal(none.length, 1);
assert.match(none[0], /не покрывает ship-задачи #709 — они не прочитаны/);
assert.match(none[0], /Запустить: gh workflow run ship-review\.yml --ref dev -f tag=v1\.79\.0-beta\.2$/);
// Документ тега уже лежит: без force его не переснять — команда с force=true.
const own = { name: 'SHIP-REVIEW-v1.79.0-beta.2.md', text: anchorBlock({ tag, candidate: sha('e'), base, issues: [701], high: 0, mode: 'beta', patches: new Map([[701, [PID('a')]]]) }) };
assert.match(shipReviewProblems({ tag, ship, docs: [own], base })[0], /не покрывает ship-задачи #702[\s\S]*-f force=true$/);
// high: прежний отказ, с документом и командой пересъёмки.
const red = shipDoc(NIGHT('777777777777'), { issues: [702], high: 1, patches: new Map([[702, [PID('b')]]]) });
const high = shipReviewProblems({ tag, ship, docs: [night, red], base });
assert.equal(high.length, 1);
assert.match(high[0], new RegExp(`docs/reviews/${NIGHT('777777777777').replace(/\./g, '\\.')} \\(задачи #702\\): High 1 — бета ждёт починки`));
assert.match(high[0], /-f force=true$/);
// Документ тега записан для чужой базы — названо явно.
const shifted = { ...own, text: own.text.replace(`base ${base}`, 'base v1.78.0') };
assert.ok(shipReviewProblems({ tag, ship, docs: [night, shifted], base }).some((p) => /записан для базы v1\.78\.0, а диапазон кандидата — от v1\.79\.0-beta\.1/.test(p)));
});
test('#727 AC5 К5: бета читает дельту — бриф только none/stale и «прочитаны ночью»; force — все; пустая дельта — «все покрыты»', () => {
const tag = 'v1.79.0-beta.2';
const base = 'v1.79.0-beta.1';
const issue = (number, patches) => ({ number, title: `Задача ${number}`, spec: '## ТЗ\n\nстрока', patches, commits: [{ sha: sha(String(number % 10)), subject: `fix: ${number}` }] });
const ship = [issue(701, [PID('a')]), issue(702, [PID('b'), PID('f')]), issue(703, [PID('c')]), issue(704, [PID('d')])];
const docs = [
shipDoc(NIGHT('111111111111'), { issues: [701, 702], high: 0, patches: new Map([[701, [PID('a')]], [702, [PID('b')]]]) }),
shipDoc(NIGHT('222222222222'), { issues: [704], high: 1, patches: new Map([[704, [PID('d')]]]) }),
];
const plan = planShipReview({ tag, ship, docs, base });
assert.equal(plan.mode, 'beta');
assert.deepEqual(plan.read.map((i) => i.number), [702, 703], 'stale и none; high не перечитывается без force');
assert.deepEqual(plan.covered, [{ number: 701, doc: NIGHT('111111111111') }]);
assert.deepEqual(plan.held, [{ number: 704, doc: NIGHT('222222222222') }]);
const brief = renderShipBrief({ tag, candidate: sha('e'), base: { tag: base, sha: sha('f') }, ship: plan.read, doc: shipReviewDocPath(tag), covered: plan.covered, held: plan.held });
assert.match(brief, /^## Задачи ship \(2\)/m);
assert.match(brief, /### #702 · /);
assert.match(brief, /### #703 · /);
assert.doesNotMatch(brief, /### #701 |### #704 /, 'прочитанные и High в бриф как задачи не входят');
assert.match(brief, new RegExp(`^- Прочитаны ночью: #701 — \`docs/reviews/${NIGHT('111111111111').replace(/\./g, '\\.')}\``, 'm'));
assert.match(brief, /^- High ждёт починки: #704 — `docs\/reviews\/SHIP-REVIEW-v1\.79\.0-beta\.1-dev-222222222222\.md`/m);
// force=true — все ship-задачи, на ночные документы не смотрит.
const forced = planShipReview({ tag, ship, docs, base, force: true });
assert.deepEqual(forced.read.map((i) => i.number), [701, 702, 703, 704]);
assert.deepEqual([forced.covered, forced.held], [[], []]);
// Пустая дельта: модели нет, сводка «все ship-задачи покрыты: <документы>».
const empty = planShipReview({ tag, ship: [ship[0]], docs, base });
assert.deepEqual(empty.read, []);
assert.equal(empty.note, `все ship-задачи покрыты: ${NIGHT('111111111111')} — модель не запускается`);
assert.match(planShipReview({ tag, ship: [ship[3]], docs, base }).note, /^читать нечего: #704 — High в .*force=true/);
assert.equal(planShipReview({ tag, ship: [], docs, base }).note, 'ship-задач в диапазоне нет — ревью не нужно');
// Ночной бриф называет кандидата головой dev и пишет в ночной документ.
const nightly = renderShipBrief({ tag: 'nightly', candidate: sha('e'), base: { tag: base, sha: sha('f') }, ship: plan.read, doc: `docs/reviews/${NIGHT()}` });
assert.match(nightly, /^# Вход ночного пакетного ревью ship$/m);
assert.match(nightly, /- Кандидат: `e{40}` — голова `dev` после ночного полного Validate/);
assert.match(nightly, new RegExp(`- Документ: \`docs/reviews/${NIGHT().replace(/\./g, '\\.')}\``));
});
test('#727 AC7 К7: индекс и архив знают ночной документ; база-бета — её линия, стабильная база — ближайшая новее', () => {
assert.deepEqual(parseDocName(NIGHT()), { stage: 'ship', issue: null, round: null, suffix: null, tag: 'v1.79.0-beta.1', nightly: true });
assert.deepEqual(parseDocName('SHIP-REVIEW-v1.78.0-dev-0123456789ab.md'), { stage: 'ship', issue: null, round: null, suffix: null, tag: 'v1.78.0', nightly: true });
assert.equal(parseDocName('SHIP-REVIEW-v1.78.0-dev-0123.md'), null, 'SHA — ровно 12 знаков');
assert.equal(parseDocName('SHIP-REVIEW-nightly.md'), null);
const entry = (name, tag, extra = {}) => ({ name, stage: 'ship', issue: null, tag, verdict: '—', high: 0, medium: 0, findings: [], files: [], ...extra });
const md = renderIndex({ entries: [
entry('SHIP-REVIEW-v1.79.0-beta.1.md', 'v1.79.0-beta.1'),
entry(NIGHT(), 'v1.79.0-beta.1', { nightly: true, high: 1 }),
entry('SHIP-REVIEW-v1.79.0-beta.2.md', 'v1.79.0-beta.2'),
] });
const rows = md.split('\n').filter((line) => /^\| (бета|ночь)/.test(line));
assert.deepEqual(rows.map((r) => r.split('|')[1].trim()), ['бета v1.79.0-beta.2', 'ночь после v1.79.0-beta.1', 'бета v1.79.0-beta.1']);
assert.match(rows[1], new RegExp(`\\[${NIGHT().replace(/\./g, '\\.')}\\]\\(${NIGHT().replace(/\./g, '\\.')}\\) \\| ночное пакетное ревью ship · — \\| ⚪ — \\| 1 \\|`));
const plan = (names, lines, through) => archivePlan({ names, lines: lines.map((tag) => ({ tag, issues: [] })), open: [], through });
const night = 'SHIP-REVIEW-v1.78.0-dev-0123456789ab.md';
// (а) база-бета → каталог её стабильной линии
assert.deepEqual(plan([NIGHT()], ['v1.79.0'], 'v1.79.0').moves.map((m) => m.to), [`legacy/reviews/v1.79.0/${NIGHT()}`]);
// (б) стабильная база → линия новее базы, не каталог самой базы
const later = plan([night], ['v1.78.0', 'v1.79.0'], 'v1.79.0');
assert.deepEqual(later.moves.map((m) => m.to), [`legacy/reviews/v1.79.0/${night}`]);
assert.ok(!later.moves.some((m) => m.to.startsWith('legacy/reviews/v1.78.0/')));
// (в) несколько линий новее — ближайшая, не последняя
assert.deepEqual(plan([night], ['v1.78.0', 'v1.78.1', 'v1.79.0'], 'v1.79.0').moves.map((m) => m.to), [`legacy/reviews/v1.78.1/${night}`]);
// (г) линии новее нет — остаётся на месте с причиной
const none = plan([night], ['v1.77.0', 'v1.78.0'], 'v1.78.0');
assert.deepEqual(none.moves, []);
assert.deepEqual(none.kept, [{ name: night, reason: 'ночное ревью после v1.78.0: архивируемой линии новее базы нет' }]);
});
test('#727 AC8 К8: строка о High ночью — с меткой документа, повтор на тот же документ не пишется', () => {
const doc = `docs/reviews/${NIGHT()}`;
const body = highCommentBody(doc);
assert.equal(body.split('\n').length, 1, 'одна строка');
assert.ok(body.startsWith(`Ночное пакетное ревью ship нашло High: \`${doc}\`. Бета не выйдет, пока находка не починена отдельной задачей и ревью не переснято (§11.7)`));
assert.match(body, new RegExp(`<!-- hp:ship-review-high doc=${NIGHT().replace(/\./g, '\\.')} -->$`));
assert.equal(highCommentBody(NIGHT()), body, 'имя и путь дают одну строку');
const issues = [
{ number: 701, comments: [{ body: 'обсуждение' }] },
{ number: 702, comments: [{ body: body }] },
{ number: 703, comments: [{ body: highCommentBody(NIGHT('999999999999')) }] },
];
assert.deepEqual(highCommentTargets({ doc, issues }), [701, 703], 'метка того же документа — повтора нет; другой документ — пишется');
});
// ---------- #727: шаги _ship-review.yml на настоящем bash и git ----------
//
// Шаги исполняются как есть, из файла workflow: подготовка (prepare),
// публикация документа и строка о High. Подменены транспорт push (github.com →
// локальный origin) и `gh` (issue из файлов песочницы, комментарии — в журнал).
// Патч-набор, база, документы диапазона и покрытие — настоящий git.
const SCRIPTS = fileURLToPath(new URL('../scripts', import.meta.url));
const SHIP_WORKFLOW = fileURLToPath(new URL('../.github/workflows/_ship-review.yml', import.meta.url));
const hasTools = () => process.platform !== 'win32'
&& ['bash', 'jq', 'sha256sum'].every((tool) => spawnSync(tool, ['--version']).status === 0);
const GIT_ENV = {
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^GIT_/i.test(key))),
GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t',
GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null',
GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'init.defaultBranch', GIT_CONFIG_VALUE_0: 'dev',
};
/** Тело `run:` шага, как его прочтёт YAML (блок кончается на строке с отступом меньше десяти). */
function stepRun(name) {
const text = readFileSync(SHIP_WORKFLOW, 'utf8');
const start = text.indexOf(` - name: ${name}\n`);
assert.ok(start >= 0, `шаг «${name}»`);
const lines = text.slice(start).split('\n');
const from = lines.indexOf(' run: |');
assert.ok(from > 0, `у шага «${name}» есть run: |`);
const body = [];
for (const line of lines.slice(from + 1)) {
if (line.trim() && !/^ {10}/.test(line)) break;
body.push(line.replace(/^ {10}/, ''));
}
return body.join('\n').replace(/\$\{\{ github\.repository \}\}/g, 'o/r');
}
/** Замыкание относительных импортов скрипта. */
function importClosure(entry, seen = new Set()) {
if (seen.has(entry)) return seen;
seen.add(entry);
for (const m of readFileSync(entry, 'utf8').matchAll(/^import[^'"]*['"](\.{1,2}\/[^'"]+)['"]/gm)) {
importClosure(join(dirname(entry), m[1]), seen);
}
return seen;
}
function shipSandbox(t) {
const root = mkdtempSync(join(tmpdir(), 'hp-727-wf-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const origin = join(root, 'origin.git');
const work = join(root, 'work');
const temp = join(root, 'runner');
const fake = join(root, 'fake');
const bin = join(root, 'bin');
for (const dir of [temp, fake, bin]) mkdirSync(dir);
const git = (cwd, ...args) => {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: GIT_ENV });
assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
};
git(root, 'init', '--bare', '-q', origin);
git(root, 'clone', '-q', origin, work);
git(work, 'checkout', '-q', '-b', 'dev');
mkdirSync(join(work, 'scripts'));
const scripts = ['ship-review.mjs', 'reviews-index.mjs', 'review-doc-guard.mjs']
.reduce((seen, name) => importClosure(join(SCRIPTS, name), seen), new Set());
for (const file of scripts) writeFileSync(join(work, 'scripts', file.slice(SCRIPTS.length + 1)), readFileSync(file));
mkdirSync(join(work, 'docs', 'reviews'), { recursive: true });
writeFileSync(join(work, 'docs', 'reviews', 'INDEX.md'), '# Индекс ревью\n');
for (const name of ['a', 'b', 'c']) writeFileSync(join(work, `${name}.mjs`), `export const ${name} = 0;\n`);
const commit = (files, message) => {
for (const [path, text] of Object.entries(files)) {
mkdirSync(dirname(join(work, path)), { recursive: true });
writeFileSync(join(work, path), text);
}
git(work, 'add', '-A');
git(work, 'commit', '-q', '-m', message);
return git(work, 'rev-parse', 'HEAD');
};
commit({}, 'base');
git(work, 'tag', 'v1.0.0');
const realGit = spawnSync('bash', ['-c', 'command -v git'], { encoding: 'utf8' }).stdout.trim();
writeFileSync(join(bin, 'git'), [
'#!/usr/bin/env bash',
'if [ "$1" = push ]; then',
' args=(); for a in "$@"; do case "$a" in https://*) args+=("$FAKE_ORIGIN") ;; *) args+=("$a") ;; esac; done',
' exec "$REAL_GIT" "${args[@]}"',
'fi',
'exec "$REAL_GIT" "$@"',
'',
].join('\n'), { mode: 0o755 });
// gh: `issue view N … --json …` — файл issue-N.json; `issue comment N … --body X` — в журнал.
writeFileSync(join(bin, 'gh'), [
'#!/usr/bin/env bash',
'echo "$*" >> "$FAKE_DIR/gh-calls"',
'if [ "$1 $2" = "issue view" ]; then cat "$FAKE_DIR/issue-$3.json"; exit 0; fi',
'if [ "$1 $2" = "issue comment" ]; then',
' while [ $# -gt 0 ]; do if [ "$1" = --body ]; then printf "%s\\t%s\\n" "$NUM" "$2" >> "$FAKE_DIR/comments"; fi; NUM=${NUM:-$3}; shift; done',
' exit 0',
'fi',
'echo "unexpected gh $*" >&2; exit 1',
'',
].join('\n'), { mode: 0o755 });
// Коммит публикации документа несёт `Issue: #696` — задача не ship.
writeFileSync(join(fake, 'issue-696.json'), JSON.stringify({ number: 696, title: 'Пакетное ревью ship', body: '', labels: [], comments: [] }));
const box = {
root, origin, work, temp, fake, git, commit,
push() { git(work, 'push', '-q', '--tags', 'origin', 'HEAD:dev'); git(work, 'fetch', '-q', 'origin'); },
issue(number, { comments = [{ body: `Слияние без ревью модели\n\n${MARKER}` }], labels = [] } = {}) {
writeFileSync(join(fake, `issue-${number}.json`), JSON.stringify({
number, title: `Задача ${number}`, body: '## ТЗ\n\nстрока', labels, comments,
}));
},
run(script, env) {
for (const file of ['output', 'summary.md']) rmSync(join(temp, file), { force: true });
const r = spawnSync('bash', ['--noprofile', '--norc', '-e', '-c', script], {
cwd: work, encoding: 'utf8',
env: {
...GIT_ENV, ...env, PATH: `${bin}:${process.env.PATH}`, RUNNER_TEMP: temp, GH_TOKEN: 'x', TOKEN: 'x',
GITHUB_OUTPUT: join(temp, 'output'), GITHUB_STEP_SUMMARY: join(temp, 'summary.md'),
FAKE_DIR: fake, FAKE_ORIGIN: origin, REAL_GIT: realGit,
},
});
const read = (path) => { try { return readFileSync(path, 'utf8'); } catch { return ''; } };
const output = {};
for (const line of read(join(temp, 'output')).split('\n')) {
const at = line.indexOf('=');
if (at > 0) output[line.slice(0, at)] = line.slice(at + 1);
}
return { status: r.status, stdout: r.stdout, stderr: r.stderr, output, summary: read(join(temp, 'summary.md')) };
},
prepare(env) {
return box.run(stepRun('Кандидат, база и ship-задачи'), {
FORCE: 'false', CANDIDATE: '', RUN_URL: 'https://github.com/o/r/actions/runs/1', ...env,
});
},
/** Публикация так, как её видит job publish: выходы prepare и запечатанный результат модели. */
publish(prepared, result, extra = {}) {
const dir = join(temp, 'ship-review-result');
rmSync(dir, { recursive: true, force: true });
mkdirSync(dir);
writeFileSync(join(dir, 'ship-review.md'), `# Пакетное ревью ship\n\nИтог: High ${result.high} · Medium ${result.medium} · Low ${result.low}\n`);
writeFileSync(join(dir, 'result.json'), JSON.stringify(result));
spawnSync('bash', ['-c', 'sha256sum ship-review.md result.json > manifest.sha256'], { cwd: dir });
const o = prepared.output;
const env = {
TAG: extra.TAG, DOC: o.doc, CANDIDATE: o.candidate, BASE: o.base, ISSUES: o.issues, MODE: o.mode, PATCHES: o.patches,
RUN_URL: 'https://github.com/o/r/actions/runs/2',
};
const published = box.run(stepRun('Опубликовать документ'), env);
git(work, 'fetch', '-q', 'origin');
return published;
},
check(tag, candidate) {
return spawnSync(process.execPath, ['scripts/ship-review.mjs', 'check', `--tag=${tag}`, `--candidate=${candidate}`, '--repo=o/r'], {
cwd: work, encoding: 'utf8', env: { ...GIT_ENV, PATH: `${bin}:${process.env.PATH}`, FAKE_DIR: fake, REAL_GIT: realGit },
});
},
};
return box;
}
test('#727 AC2/AC5 _ship-review.yml на настоящем bash: ночь читает непокрытое, бета — дельту, гейт принимает ночной документ', (t) => {
if (!hasTools()) { t.skip('bash/jq/sha256sum недоступны'); return; }
const box = shipSandbox(t);
box.issue(701);
box.issue(702);
box.issue(703, { comments: [] }); // show: в пакет не входит
box.commit({ 'a.mjs': 'export const a = 1;\n' }, 'fix: a (#701)\n\nIssue: #701\nUser-Visible: no');
box.commit({ 'b.mjs': 'export const b = 1;\n' }, 'fix: b (#702)\n\nIssue: #702\nUser-Visible: no');
box.commit({ 'c.mjs': 'export const c = 1;\n' }, 'feat: c (#703)\n\nIssue: #703\nUser-Visible: no');
box.push();
const head = box.git(box.work, 'rev-parse', 'HEAD');
// tag=nightly без candidate — отказ до подстановки вершины dev.
const refused = box.prepare({ TAG: 'nightly' });
assert.notEqual(refused.status, 0);
assert.match(refused.stderr, /tag=nightly требует candidate/);
assert.equal(refused.output.proceed, undefined);
// Первая ночь: читать обе ship-задачи, документ по базе и SHA.
const first = box.prepare({ TAG: 'nightly', CANDIDATE: head });
assert.equal(first.status, 0, first.stderr);
const nightDoc = `docs/reviews/SHIP-REVIEW-v1.0.0-dev-${head.slice(0, 12)}.md`;
assert.deepEqual([first.output.proceed, first.output.mode, first.output.doc, first.output.base, first.output.issues],
['true', 'nightly', nightDoc, 'v1.0.0', '701,702']);
assert.match(first.output.patches, /^701:[0-9a-f]{40},702:[0-9a-f]{40}$/);
assert.equal(first.output.subject, 'Ночное пакетное ревью: кандидат — голова `dev` после ночного полного Validate.');
assert.match(readFileSync(join(box.temp, 'ship-review-input', 'brief.md'), 'utf8'), /^# Вход ночного пакетного ревью ship$/m);
// Публикация: mode и patches — из prepare; поле patches в результате модели не читается.
const bogus = { high: 0, medium: 1, low: 0, summary: 'ok', patches: '701:' + 'd'.repeat(40) };
const published = box.publish(first, bogus, { TAG: 'nightly' });
assert.equal(published.status, 0, published.stderr + published.stdout);
const text = box.git(box.work, 'show', `origin/dev:${nightDoc}`);
const block = parseAnchorBlock(text);
assert.equal(block.mode, 'nightly');
assert.equal(block.base, 'v1.0.0');
assert.deepEqual(block.issues, [701, 702]);
assert.equal(formatPatches(block.patches), first.output.patches, 'patches — ровно из prepare');
assert.match(box.git(box.work, 'show', 'origin/dev:docs/reviews/INDEX.md'), /ночь после v1\.0\.0/);
// Документ на этот SHA уже в dev — ночь ничего не делает.
const again = box.prepare({ TAG: 'nightly', CANDIDATE: head });
assert.equal(again.status, 0, again.stderr);
assert.equal(again.output.proceed, 'false');
assert.match(again.stdout, /уже есть в dev — повторное ревью не запускается/);
// Кандидат беты — Release:-коммит поверх ночной головы: гейт принимает ночной документ, документа тега нет.
box.git(box.work, 'reset', '-q', '--hard', 'origin/dev');
const release = box.commit({ 'package.json': '{"version":"1.1.0-beta.1"}\n' },
'Release v1.1.0-beta.1 candidate\n\nIssue: #701\nIssue: #702\nIssue: #703\nUser-Visible: yes\nRelease: v1.1.0-beta.1');
const pass = box.check('v1.1.0-beta.1', release);
assert.equal(pass.status, 0, pass.stderr);
assert.match(pass.stdout, new RegExp(`#701 — ${nightDoc.replace(/\./g, '\\.')}, #702 — ${nightDoc.replace(/\./g, '\\.')}`));
box.git(box.work, 'reset', '-q', '--hard', 'origin/dev');
// Пустая дельта беты — модели нет, сводка «все ship-задачи покрыты».
const covered = box.prepare({ TAG: 'v1.1.0-beta.1' });
assert.equal(covered.status, 0, covered.stderr);
assert.equal(covered.output.proceed, 'false');
assert.equal(covered.output.issues, '');
assert.match(covered.summary, new RegExp(`^- все ship-задачи покрыты: SHIP-REVIEW-v1\\.0\\.0-dev-${head.slice(0, 12)}\\.md — модель не запускается$`, 'm'));
// Документ только в docs/reviews/** с трейлером задачи — покрытие держится.
box.commit({ 'docs/reviews/CODE-REVIEW-702-r1.md': '# r1\nВердикт: **зелёный** · High: 0 · Medium: 0\n' }, 'docs: review document for #702\n\nIssue: #702\nUser-Visible: no');
// #701 получила коммит после ночи → stale; гейт отказывает, бета читает только её.
box.commit({ 'a.mjs': 'export const a = 2;\n' }, 'fix: a again (#701)\n\nIssue: #701\nUser-Visible: no');
box.push();
const tip = box.git(box.work, 'rev-parse', 'HEAD');
const stale = box.check('v1.1.0-beta.1', tip);
assert.equal(stale.status, 1);
assert.match(stale.stderr, new RegExp(`::error::ship-задача #701 изменилась после ревью ${nightDoc.replace(/\./g, '\\.')}`));
assert.doesNotMatch(stale.stderr, /#702/, 'коммит только в docs/reviews/** покрытие не снимает');
const delta = box.prepare({ TAG: 'v1.1.0-beta.1' });
assert.equal(delta.status, 0, delta.stderr);
assert.deepEqual([delta.output.proceed, delta.output.mode, delta.output.doc, delta.output.issues],
['true', 'beta', 'docs/reviews/SHIP-REVIEW-v1.1.0-beta.1.md', '701']);
assert.match(delta.output.patches, /^701:[0-9a-f]{40}\+[0-9a-f]{40}$/);
const brief = readFileSync(join(box.temp, 'ship-review-input', 'brief.md'), 'utf8');
assert.match(brief, new RegExp(`^- Прочитаны ночью: #702 — \`${nightDoc.replace(/\./g, '\\.')}\``, 'm'));
assert.doesNotMatch(brief, /### #702 /);
// force=true — все ship-задачи, ночные документы не в счёт.
const forced = box.prepare({ TAG: 'v1.1.0-beta.1', FORCE: 'true' });
assert.equal(forced.output.issues, '701,702');
// Ночь после: High на #701 → задача держится, следующие ночи её не читают, гейт стоит.
const night2 = box.prepare({ TAG: 'nightly', CANDIDATE: tip });
assert.equal(night2.output.issues, '701');
assert.equal(box.publish(night2, { high: 1, medium: 0, low: 0, summary: 'x' }, { TAG: 'nightly' }).status, 0);
box.git(box.work, 'reset', '-q', '--hard', 'origin/dev');
box.commit({ 'c.mjs': 'export const c = 2;\n' }, 'feat: c again (#703)\n\nIssue: #703\nUser-Visible: no');
box.push();
const night3 = box.prepare({ TAG: 'nightly', CANDIDATE: box.git(box.work, 'rev-parse', 'HEAD') });
assert.equal(night3.status, 0, night3.stderr);
assert.equal(night3.output.proceed, 'false', 'High не перечитывается без force');
assert.match(night3.summary, /читать нечего: #701 — High в SHIP-REVIEW-v1\.0\.0-dev-[0-9a-f]{12}\.md/);
const high = box.check('v1.1.0-beta.1', box.git(box.work, 'rev-parse', 'HEAD'));
assert.equal(high.status, 1);
assert.match(high.stderr, /\(задачи #701\): High 1 — бета ждёт починки[^\n]*-f force=true/);
// Кандидат не предок dev — отказ prepare.
box.git(box.work, 'checkout', '-q', '-b', 'side', 'HEAD~1');
const side = box.commit({ 'b.mjs': 'export const b = 9;\n' }, 'fix: side (#702)\n\nIssue: #702\nUser-Visible: no');
const foreign = box.prepare({ TAG: 'nightly', CANDIDATE: side });
assert.notEqual(foreign.status, 0);
assert.match(foreign.stderr, /не предок origin\/dev/);
});
test('#727 AC8 _ship-review.yml на настоящем bash: строка о High — только ночью и только при High > 0, без повтора', (t) => {
if (!hasTools()) { t.skip('bash/jq/sha256sum недоступны'); return; }
const box = shipSandbox(t);
const doc = 'docs/reviews/SHIP-REVIEW-v1.0.0-dev-0123456789ab.md';
const step = stepRun('High ночью — строка в задачи документа');
const run = (mode, high) => {
const dir = join(box.temp, 'ship-review-result');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'result.json'), JSON.stringify({ high, medium: 3, low: 1, summary: 'x' }));
rmSync(join(box.fake, 'comments'), { force: true });
const r = box.run(step, { MODE: mode, DOC: doc, ISSUES: '701,702' });
assert.equal(r.status, 0, r.stderr);
let comments = '';
try { comments = readFileSync(join(box.fake, 'comments'), 'utf8'); } catch { /* нет комментариев */ }
return comments.split('\n').filter(Boolean);
};
box.issue(701, { comments: [] });
box.issue(702, { comments: [{ body: highCommentBody(doc) }] });
assert.deepEqual(run('beta', 2), [], 'бета: High держит гейт, строки в задачи нет');
assert.deepEqual(run('nightly', 0), [], 'ночь без High: Medium и Low в issue не пишутся');
assert.deepEqual(run('nightly', 2), [`701\t${highCommentBody(doc)}`], 'в #702 строка о том же документе уже есть');
const workflow = readFileSync(SHIP_WORKFLOW, 'utf8');
const at = workflow.indexOf(' - name: High ночью — строка в задачи документа');
assert.match(workflow.slice(at, workflow.indexOf('run: |', at)), /if: needs\.prepare\.outputs\.mode == 'nightly'\n[\s\S]*GH_TOKEN: \$\{\{ secrets\.HP_PROCESS_TOKEN \}\}/);
});
test('#727 AC2 _ship-review.yml: ночной режим в теле, patches — не из результата модели, run без heredoc в новых шагах', () => {
const workflow = readFileSync(SHIP_WORKFLOW, 'utf8');
const prepare = stepRun('Кандидат, база и ship-задачи');
// nightly без candidate отказывает до подстановки вершины dev
assert.ok(prepare.indexOf('ship-review.mjs mode --tag="$TAG" --candidate="$CANDIDATE"') < prepare.indexOf('CANDIDATE=$(git rev-parse origin/dev)'));
assert.match(prepare, /doc=\$\(node scripts\/ship-review\.mjs doc --tag="\$TAG" --candidate="\$CANDIDATE"\)/);
assert.match(prepare, /--force="\$FORCE"/);
assert.match(prepare, /^set -o pipefail$/m, 'отказ prepare за | tee — не «ship-задач нет»');
for (const output of ['mode', 'patches', 'subject']) {
assert.match(workflow, new RegExp(`\\n ${output}: \\$\\{\\{ steps\\.range\\.outputs\\.${output} \\}\\}`));
}
const model = workflow.slice(workflow.indexOf('\n model_review:'), workflow.indexOf('\n publish:'));
assert.match(model, /\$\{\{ needs\.prepare\.outputs\.subject \}\}/, 'промпт называет кандидата по режиму');
assert.doesNotMatch(model, /Бета: \$\{\{ inputs\.tag \}\}/);
const publish = stepRun('Опубликовать документ');
assert.match(publish, /mode: process\.env\.MODE, patches: m\.parsePatches\(process\.env\.PATCHES\)/);
assert.match(workflow, /\n {10}PATCHES: \$\{\{ needs\.prepare\.outputs\.patches \}\}\n/);
assert.deepEqual([...publish.matchAll(/jq -r '([^']+)'/g)].map((m) => m[1]), ['.high', '.medium', '.low'], 'из результата модели — только счёт');
assert.match(publish, /doc --tag="\$TAG" --candidate="\$CANDIDATE"/);
for (const body of [prepare, stepRun('High ночью — строка в задачи документа')]) {
assert.doesNotMatch(body, /<<-?\s*['"]?[A-Za-z_]/, 'heredoc в run');
assert.equal(spawnSync('bash', ['-n', '-c', body]).status, 0, 'bash -n');
}
assert.equal(spawnSync('bash', ['-n', '-c', publish]).status, 0, 'bash -n публикации');
});