mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 04:09:17 +00:00
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
715 lines
53 KiB
JavaScript
715 lines
53 KiB
JavaScript
// #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 публикации');
|
||
});
|