Files
houseplan-card/scripts/ship-review.mjs
T
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

591 lines
34 KiB
JavaScript
Raw 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.
#!/usr/bin/env node
/**
* Пакетное ревью задач `track:ship` перед бетой (#696, PROCESS.md §11.7).
*
* node scripts/ship-review.mjs doc --tag=v1.79.0-beta.1
* node scripts/ship-review.mjs doc --tag=nightly --candidate=<sha>
* node scripts/ship-review.mjs mode --tag=<тег|nightly> [--candidate=<sha>]
* node scripts/ship-review.mjs prepare --tag=<тег|nightly> --candidate=<sha> --out=<dir> [--force=true] [--repo=owner/name]
* node scripts/ship-review.mjs check --tag=<тег> --candidate=<sha> [--repo=owner/name]
* node scripts/ship-review.mjs comment-high --mode=<nightly|beta> --high=<N> --doc=<путь> --issues=<NN,…> [--repo=owner/name]
*
* `ship` сливается без ревью модели (§5): правка в механических рамках и
* зелёный лёгкий Validate. Прочитать её код обязан кто-то до того, как она
* уйдёт пользователям, — это пакетное ревью всех ship-задач диапазона
* «прошлый тег..кандидат беты» одной сессией модели.
*
* Какие задачи — ship, доказывает конвейер, а не метка: слияние без модели
* оставляет в issue машинный маркер `<!-- hp:ship-merge material=<sha> -->`.
* Метка `track:ship` на issue в диапазоне тоже включает задачу в пакет —
* лишний разбор дешевле пропущенного. Состав диапазона — трейлеры `Issue: #NN`,
* тот же построитель, что у `RELEASE-MEMBERSHIP.json` (#547).
*
* `check` — гейт публикации беты: каждая ship-задача диапазона покрыта
* документами ревью той же базы (`shipCoverage`, #727) — последний документ,
* где она есть, прочитал тот же патч-набор и не несёт High.
*
* Ночной режим (#727): `tag=nightly` — ночь после полного Validate читает
* непокрытые ship-задачи головы `dev` и пишет
* `docs/reviews/SHIP-REVIEW-<база>-dev-<sha12>.md`. Бета затем читает только
* дельту — задачи без покрытия или изменившиеся после ревью.
*/
import { mkdirSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
import { isMainModule } from './spawn-portable.mjs';
import { issueTrailers, readCandidateHistory } from './release-membership.mjs';
import { parseDocName } from './reviews-index.mjs';
export const SHIP_REVIEW_DIR = 'docs/reviews';
/** #727: зарезервированное значение входа `tag` — ночной режим ship-ревью. */
export const NIGHTLY_TAG = 'nightly';
/** #727 К8: метка комментария о High ночного документа; одна на документ и задачу. */
export const SHIP_HIGH_MARKER_RE = /<!-- hp:ship-review-high doc=([^\s>]+) -->/g;
export const SHIP_MERGE_MARKER_RE = /<!-- hp:ship-merge material=([0-9a-f]{40}) -->/;
/** #707: риск по участкам, с которым ship слит (трек подтверждён владельцем или риск только visual). */
export const SHIP_RISK_MARKER_RE = /<!-- hp:ship-risk classes=([a-z,]+) -->/;
export const SHIP_REVIEW_ANCHOR = '<!-- hp-ship-review-anchors -->';
export const RELEASE_TAG_RE = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-beta\.(0|[1-9]\d*))?$/;
const SHA_RE = /^[0-9a-f]{40,64}$/;
const PATCH_ID_RE = /^[0-9a-f]{40,64}$/;
const TZ_LIMIT = 1500;
/** Путь документа пакетного ревью для тега беты или стабильного. */
export function shipReviewDocPath(tag) {
if (!RELEASE_TAG_RE.test(String(tag))) throw new Error(`not a release tag: ${tag}`);
return `${SHIP_REVIEW_DIR}/SHIP-REVIEW-${tag}.md`;
}
/**
* #727 К2: режим по входу `tag`. `nightly` зарезервирован; кандидат в нём
* обязателен — это голова `dev` прогона ночного Validate, а не «вершина сейчас».
*/
export function shipReviewMode({ tag, candidate = '' } = {}) {
if (tag === NIGHTLY_TAG) {
if (!String(candidate || '').trim()) {
throw new Error('tag=nightly требует candidate — SHA головы dev, на которой шёл ночной полный Validate');
}
return 'nightly';
}
shipReviewDocPath(tag);
return 'beta';
}
/** #727 К2: ночной документ — по базе (прошлый тег кандидата) и SHA кандидата. */
export function nightlyDocPath({ base, candidate }) {
if (!RELEASE_TAG_RE.test(String(base || ''))) {
throw new Error(`ночной документ без базы: у кандидата ${candidate} нет прошлого тега (${base || '—'})`);
}
if (!SHA_RE.test(String(candidate))) throw new Error(`invalid candidate SHA: ${candidate}`);
return `${SHIP_REVIEW_DIR}/SHIP-REVIEW-${base}-dev-${candidate.slice(0, 12)}.md`;
}
/** Документ прогона: ночной — по базе и SHA, бета — по тегу. */
export function shipDocPath({ tag, candidate = '', base = null }) {
return shipReviewMode({ tag, candidate }) === 'nightly' ? nightlyDocPath({ base, candidate }) : shipReviewDocPath(tag);
}
const docName = (path) => String(path).slice(String(path).lastIndexOf('/') + 1);
const docPath = (name) => `${SHIP_REVIEW_DIR}/${name}`;
/** Задача — ship, если конвейер слил её без модели или на ней стоит `track:ship`. */
export function isShipIssue({ labels = [], comments = [] } = {}) {
const names = labels.map((label) => (typeof label === 'string' ? label : label?.name));
return names.includes('track:ship') || comments.some((c) => SHIP_MERGE_MARKER_RE.test(String(c?.body ?? '')));
}
/**
* Строка риска из последнего комментария слияния ship (#707): `{ classes, line }`
* или null. Комментарии задач до #707 строки не несут — риск не записан.
*/
export function shipRiskFrom(comments = []) {
const merges = (comments || []).map((c) => String(c?.body ?? '')).filter((body) => SHIP_MERGE_MARKER_RE.test(body));
const body = merges.at(-1);
const marker = body && SHIP_RISK_MARKER_RE.exec(body);
if (!marker) return null;
const classes = marker[1].split(',').filter(Boolean);
const line = body.split('\n').map((l) => l.trim()).find((l) => l.startsWith('Риск по участкам'))
|| `Риск по участкам: ${classes.join(', ')}`;
return { classes, line };
}
/** Раздел `## ТЗ` тела issue — для ship это строка «что меняется и чем проверить». */
export function specSection(body = '') {
const text = String(body);
const start = text.search(/^#{1,3}\s*ТЗ(?![\p{L}\p{N}_])/mu);
if (start < 0) return '';
const rest = text.slice(start).split('\n');
const out = [rest[0]];
for (const line of rest.slice(1)) {
if (/^#{1,3}\s/.test(line)) break;
out.push(line);
}
const section = out.join('\n').trim();
return section.length > TZ_LIMIT ? `${section.slice(0, TZ_LIMIT)}…` : section;
}
/**
* Ship-задачи диапазона: номера из трейлеров коммитов, признак ship — из
* данных issue. `issueData(number)` → `{ title, body, labels, comments }` или null.
*/
export function shipIssuesInRange({ commits = [], issueData }) {
const byIssue = new Map();
for (const commit of commits) {
for (const number of issueTrailers(commit.message)) {
if (!byIssue.has(number)) byIssue.set(number, []);
byIssue.get(number).push({ sha: commit.sha, subject: String(commit.message).split('\n')[0] });
}
}
const out = [];
for (const [number, list] of [...byIssue].sort((a, b) => a[0] - b[0])) {
const data = issueData(number);
if (!data || !isShipIssue(data)) continue;
const risk = shipRiskFrom(data.comments);
out.push({ number, title: data.title || '', spec: specSection(data.body), ...(risk ? { risk } : {}), commits: list.reverse() });
}
return out;
}
/** #727 К1: трейлер `Release:` — кандидат беты, коммит бота `beta-derived`, приёмка эталонов. */
export function hasReleaseTrailer(message = '') {
return String(message).split(/\r?\n/).some((line) => /^Release:\s*\S/.test(line.trim()));
}
/**
* #727 К1: входит ли коммит задачи в её патч-набор. Не входят коммиты с
* трейлером `Release:` (кандидат беты несёт `Issue:` всех задач линии) и
* коммиты только в `docs/reviews/**` (индекс, документ ревью): без этого набор
* ночи никогда не совпадёт с набором на кандидате.
*/
export function countsForPatchSet({ message = '', files = [] } = {}) {
if (hasReleaseTrailer(message)) return false;
return !(files.length && files.every((file) => String(file).startsWith(`${SHIP_REVIEW_DIR}/`)));
}
// Дифф для patch-id — с явными опциями: конфиг git владельца (`npm run
// release:prerelease` судит у него) не должен менять форму диффа и с ней набор.
const PATCH_DIFF = [
'-c', 'core.quotePath=true', 'diff-tree', '--stdin', '-r', '--root', '-p', '--no-color', '--no-ext-diff',
'--no-textconv', '--no-renames', '--no-relative', '--diff-algorithm=myers', '--indent-heuristic', '-U3',
'--inter-hunk-context=0', '--src-prefix=a/', '--dst-prefix=b/',
];
/**
* `git patch-id --stable` коммитов: Map sha → patch-id. Коммит без диффа
* (пустой, слияние) patch-id не имеет и в Map не попадает.
*/
export function commitPatchIds(shas, { cwd } = {}) {
const list = [...new Set(shas)];
if (!list.length) return new Map();
const diff = git(PATCH_DIFF, { cwd, input: `${list.join('\n')}\n` });
const ids = git(['patch-id', '--stable'], { cwd, input: diff });
const out = new Map();
for (const line of ids.split('\n')) {
const [pid, sha] = line.trim().split(/\s+/);
if (pid && sha && list.includes(sha)) out.set(sha, pid);
}
return out;
}
/** Изменённые файлы коммитов: Map sha → [путь]. */
export function commitFiles(shas, { cwd } = {}) {
const list = [...new Set(shas)];
const out = new Map(list.map((sha) => [sha, []]));
if (!list.length) return out;
const text = git(['diff-tree', '--stdin', '-r', '--root', '--no-renames', '--name-only'], { cwd, input: `${list.join('\n')}\n` });
let current = null;
for (const line of text.split('\n')) {
if (out.has(line)) { current = line; continue; }
if (current && line) out.get(current).push(line);
}
return out;
}
/**
* #727 К1: патч-набор каждой задачи — отсортированные `git patch-id --stable`
* её коммитов диапазона (`readCandidateHistory`), без `Release:` и коммитов
* только в `docs/reviews/**`. Порядок коммитов не важен.
* @returns {Map<number, string[]>}
*/
export function issuePatchSets({ commits = [], numbers = [], cwd } = {}) {
const wanted = new Set(numbers.map(Number));
const sets = new Map([...wanted].map((number) => [number, new Set()]));
const relevant = commits.filter((commit) => issueTrailers(commit.message).some((number) => wanted.has(number)));
const files = commitFiles(relevant.map((commit) => commit.sha), { cwd });
const counted = relevant.filter((commit) => countsForPatchSet({ message: commit.message, files: files.get(commit.sha) }));
const ids = commitPatchIds(counted.map((commit) => commit.sha), { cwd });
for (const commit of counted) {
const pid = ids.get(commit.sha);
if (!pid) continue;
for (const number of issueTrailers(commit.message)) if (wanted.has(number)) sets.get(number).add(pid);
}
return new Map([...sets].sort((a, b) => a[0] - b[0]).map(([number, set]) => [number, [...set].sort()]));
}
/** `patches` машинного блока: `<NN>:<pid>+<pid>,…`, задачи и patch-id по порядку. */
export function formatPatches(patches) {
const entries = [...(patches instanceof Map ? patches : new Map(Object.entries(patches || {})))]
.map(([number, pids]) => [Number(number), [...new Set(pids || [])].sort()])
.sort((a, b) => a[0] - b[0]);
return entries.length ? entries.map(([number, pids]) => `${number}:${pids.join('+')}`).join(',') : '—';
}
/** Обратное к `formatPatches`: Map номер → отсортированные patch-id. Испорченная запись пропускается. */
export function parsePatches(text = '') {
const out = new Map();
const value = String(text ?? '').trim();
if (!value || value === '—') return out;
for (const entry of value.split(',')) {
const match = /^([1-9]\d*):([0-9a-f+]*)$/.exec(entry.trim());
if (!match) continue;
const pids = match[2].split('+').filter(Boolean);
if (pids.some((pid) => !PATCH_ID_RE.test(pid))) continue;
out.set(Number(match[1]), [...new Set(pids)].sort());
}
return out;
}
function samePatchSet(recorded, current) {
if (!Array.isArray(recorded) || !Array.isArray(current) || recorded.length !== current.length) return false;
const [a, b] = [[...recorded].sort(), [...current].sort()];
return a.every((pid, i) => pid === b[i]);
}
/**
* #727 К3: покрытие ship-задач документами пакетного ревью той же базы.
* `docs` — по возрастанию публикации (последний — позже всех), `{ name, text }`
* или `{ name, block }`. `base` — тег базы диапазона; `undefined` — не фильтровать.
*
* - `clean` — последний документ, где задача есть, записал тот же патч-набор, High 0;
* - `high` — то же, но High > 0 (или числа High нет): High снимает покрытие со всех задач документа;
* - `stale` — последний документ записал другой патч-набор: код изменился после ревью;
* - `none` — задачи нет ни в одном документе.
* Документ без строки `patches` (до #727) покрывает по номеру.
*/
export function shipCoverage({ ship = [], docs = [], base } = {}) {
const read = docs.map((doc) => ({ name: doc.name, block: doc.block ?? parseAnchorBlock(doc.text ?? '') }))
.filter((doc) => doc.block && (base === undefined || (doc.block.base ?? null) === (base ?? null)));
return ship.map((issue) => {
const last = read.findLast((doc) => doc.block.issues.includes(issue.number));
if (!last) return { number: issue.number, status: 'none', doc: null };
const { block } = last;
if (block.patches && !samePatchSet(block.patches.get(issue.number), issue.patches)) {
return { number: issue.number, status: 'stale', doc: last.name };
}
return { number: issue.number, status: block.high === 0 ? 'clean' : 'high', doc: last.name };
});
}
const NO_SHIP_NOTE = 'ship-задач в диапазоне нет — ревью не нужно';
/**
* #727 К2, К5: что читать этому прогону. Без `force` — только `none` и
* `stale`; `high` держит гейт до починки и `force=true`, `clean` уже прочитаны.
* `force` читает все ship-задачи диапазона и на документы не смотрит.
*/
export function planShipReview({ tag, ship = [], docs = [], base = null, force = false } = {}) {
const mode = tag === NIGHTLY_TAG ? 'nightly' : 'beta';
if (force) return { mode, read: ship, covered: [], held: [], note: ship.length ? '' : NO_SHIP_NOTE };
const coverage = shipCoverage({ ship, docs, base });
const status = new Map(coverage.map((item) => [item.number, item]));
const read = ship.filter((issue) => ['none', 'stale'].includes(status.get(issue.number).status));
const covered = coverage.filter((item) => item.status === 'clean').map(({ number, doc }) => ({ number, doc }));
const held = coverage.filter((item) => item.status === 'high').map(({ number, doc }) => ({ number, doc }));
let note = '';
if (!ship.length) note = NO_SHIP_NOTE;
else if (!read.length && !held.length) note = `все ship-задачи покрыты: ${[...new Set(covered.map((item) => item.doc))].join(', ')} — модель не запускается`;
else if (!read.length) {
note = `читать нечего: ${held.map((item) => `#${item.number} — High в ${item.doc}`).join(', ')}`
+ ' — гейт беты стоит до починки и пересъёмки (force=true)';
}
return { mode, read, covered, held, note };
}
/** #727 К2: строка промпта о кандидате. */
export function reviewSubject(tag) {
return tag === NIGHTLY_TAG
? 'Ночное пакетное ревью: кандидат — голова `dev` после ночного полного Validate.'
: `Бета: ${tag}.`;
}
export function renderShipBrief({ tag, candidate, base, ship, runUrl = '', doc = null, covered = [], held = [] }) {
const nightly = tag === NIGHTLY_TAG;
const byDoc = (items) => items.map((item) => `#${item.number} — \`${docPath(item.doc)}\``).join(', ');
const atNight = covered.filter((item) => parseDocName(item.doc)?.nightly);
const before = covered.filter((item) => !parseDocName(item.doc)?.nightly);
const lines = [
nightly ? '# Вход ночного пакетного ревью ship' : `# Вход пакетного ревью ship ${tag}`,
'',
`- Кандидат: \`${candidate}\`${nightly ? ' — голова `dev` после ночного полного Validate' : ''}`,
`- Диапазон: ${base ? `\`${base.tag}\` · \`${base.sha}\`` : 'нет прошлого тега — всё дерево'} .. кандидат`,
...(runUrl ? [`- Прогон: ${runUrl}`] : []),
`- Документ: \`${doc || shipReviewDocPath(tag)}\``,
...(atNight.length ? [`- Прочитаны ночью: ${byDoc(atNight)} — не перечитывать`] : []),
...(before.length ? [`- Прочитаны прежним ревью: ${byDoc(before)} — не перечитывать`] : []),
...(held.length ? [`- High ждёт починки: ${byDoc(held)} — не перечитывать, их держит гейт беты (§11.7)`] : []),
'',
`## Задачи ship (${ship.length}) — слиты без ревью модели`,
'',
];
for (const issue of ship) {
lines.push(`### #${issue.number} · ${issue.title}`, '');
lines.push(issue.spec ? issue.spec : '(раздела «## ТЗ» в теле нет — ТЗ задачи не записано, это находка)', '');
if (issue.risk?.line) lines.push(issue.risk.line, '');
lines.push('Коммиты (`git show <sha>`):', '');
for (const commit of issue.commits) lines.push(`- \`${commit.sha}\` ${commit.subject}`);
lines.push('');
}
return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trim()}\n`;
}
/**
* Машинный блок документа: его пишет публикация, читает `check`. #727: строки
* `mode` и `patches` дописываются в конец, прежние строки не меняются.
*/
export function anchorBlock({
tag, candidate, base = null, issues = [], high = 0, medium = 0, low = 0, runUrl = '', mode = null, patches = null,
}) {
return [
SHIP_REVIEW_ANCHOR,
'### Материал пакетного ревью',
'',
'```',
`tag ${tag}`,
`candidate ${candidate}`,
`base ${base || '—'}`,
`issues ${issues.length ? issues.join(',') : '—'}`,
`high ${high}`,
`medium ${medium}`,
`low ${low}`,
`run ${runUrl || '—'}`,
...(mode ? [`mode ${mode}`] : []),
...(patches ? [`patches ${formatPatches(patches)}`] : []),
'```',
'',
].join('\n');
}
/**
* Поля машинного блока. `base`, `mode` и `patches` появляются, только если
* блок их несёт: документ до #727 без `patches` покрывает задачи по номеру.
*/
export function parseAnchorBlock(text = '') {
const at = String(text).lastIndexOf(SHIP_REVIEW_ANCHOR);
if (at < 0) return null;
const fence = /```\n([\s\S]*?)\n```/.exec(String(text).slice(at));
if (!fence) return null;
const fields = Object.fromEntries(fence[1].split('\n').map((line) => {
const space = line.indexOf(' ');
return space < 0 ? [line, ''] : [line.slice(0, space), line.slice(space + 1).trim()];
}));
const number = (value) => (/^\d+$/.test(String(value)) ? Number(value) : null);
const given = (value) => value != null && value !== '' && value !== '—';
return {
tag: fields.tag || null,
candidate: fields.candidate || null,
issues: String(fields.issues || '').split(',').map((s) => number(s.trim())).filter((n) => n != null),
high: number(fields.high),
medium: number(fields.medium),
low: number(fields.low),
...(given(fields.base) ? { base: fields.base } : {}),
...(given(fields.mode) ? { mode: fields.mode } : {}),
...('patches' in fields ? { patches: parsePatches(fields.patches) } : {}),
};
}
/**
* Причины не публиковать бету; пустой список — гейт пройден. Без ship-задач в
* диапазоне документ не нужен: пакетному ревью нечего читать.
*
* #727 К4: судит покрытие (`shipCoverage`) документами той же базы `docs` —
* ночными и документом тега. Все задачи `clean` — гейт пройден и без
* документа тега. `docText` — прежний вызов: только документ тега.
*/
export function shipReviewProblems({ tag, ship = [], docText = null, docs = null, base } = {}) {
if (!ship.length) return [];
const doc = shipReviewDocPath(tag);
const run = `gh workflow run ship-review.yml --ref dev -f tag=${tag}`;
const range = docs ?? (docText ? [{ name: docName(doc), text: docText }] : []);
const own = range.find((item) => item.name === docName(doc)) || null;
const problems = [];
if (own) {
const block = parseAnchorBlock(own.text);
if (!block) return [`${doc} без машинного блока ${SHIP_REVIEW_ANCHOR}: покрытие задач не доказано. Переснять: ${run} -f force=true`];
if (block.tag !== tag) problems.push(`${doc} записан для тега ${block.tag}, а публикуется ${tag}`);
if (base !== undefined && (block.base ?? null) !== (base ?? null)) {
problems.push(`${doc} записан для базы ${block.base ?? '—'}, а диапазон кандидата — от ${base ?? '—'}`);
}
}
// Документ тега уже в dev: ship-review.yml без force его не переснимает.
const rerun = own ? `${run} -f force=true` : run;
const coverage = shipCoverage({ ship, docs: range, base });
const list = (numbers) => numbers.map((n) => `#${n}`).join(', ');
const sameBase = range.filter((item) => base === undefined || (parseAnchorBlock(item.text)?.base ?? null) === (base ?? null));
const missing = coverage.filter((item) => item.status === 'none').map((item) => item.number);
if (missing.length) {
problems.push(own || sameBase.length
? `${own ? doc : `ни один документ ревью базы ${base ?? '—'} (${sameBase.map((item) => item.name).join(', ')})`} не покрывает ship-задачи ${list(missing)} — они не прочитаны: слиты после ревью. Запустить: ${rerun}`
: `${doc} нет ни в кандидате, ни в dev: ship-задачи ${list(missing)} слиты без ревью модели, и их код до беты не читал никто (PROCESS.md §11.7). Запустить: ${run}`);
}
for (const item of coverage.filter((entry) => entry.status === 'stale')) {
problems.push(`ship-задача #${item.number} изменилась после ревью ${docPath(item.doc)}: в нём записан другой патч-набор, новый код не прочитан. Запустить: ${rerun}`);
}
for (const name of [...new Set(coverage.filter((item) => item.status === 'high').map((item) => item.doc))]) {
const block = parseAnchorBlock(range.findLast((item) => item.name === name)?.text ?? '') ?? {};
const where = `${docPath(name)} (задачи ${list(coverage.filter((item) => item.doc === name && item.status === 'high').map((item) => item.number))})`;
if (block.high == null) problems.push(`${where}: в машинном блоке нет числа High`);
else if (block.high > 0) problems.push(`${where}: High ${block.high} — бета ждёт починки: находка чинится отдельной задачей, затем ревью переснимается: ${run} -f force=true`);
}
return problems;
}
/** #727 К8: строка в задачу ночного документа с High. */
export function highCommentBody(doc) {
const path = String(doc).includes('/') ? String(doc) : docPath(doc);
return `Ночное пакетное ревью ship нашло High: \`${path}\`. Бета не выйдет, пока находка не починена отдельной задачей`
+ ` и ревью не переснято (§11.7) <!-- hp:ship-review-high doc=${docName(path)} -->`;
}
/** #727 К8: задачи, куда строка ещё не писалась — повтор на тот же документ не пишется. */
export function highCommentTargets({ doc, issues = [] }) {
const name = docName(doc);
return issues.filter((issue) => !(issue.comments || []).some((comment) => [...String(comment?.body ?? '')
.matchAll(SHIP_HIGH_MARKER_RE)].some((match) => match[1] === name))).map((issue) => issue.number);
}
function git(args, { allowFailure = false, cwd, input } = {}) {
const r = spawnSync('git', args, { cwd, input, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
if (r.status !== 0 && !allowFailure) throw new Error(`git ${args.join(' ')} failed: ${(r.stderr || '').trim()}`);
return r.status === 0 ? r.stdout : null;
}
function gh(args) {
const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
if (r.status !== 0) throw new Error(`gh ${args.slice(0, 3).join(' ')}: ${(r.stderr || r.stdout || '').trim()}`);
return r.stdout;
}
function ghIssue(repo, number) {
return JSON.parse(gh(['issue', 'view', String(number), '--repo', repo, '--json', 'number,title,body,labels,comments']));
}
/**
* #727 К3: документы пакетного ревью из кандидата и из `devRef`, по
* возрастанию публикации. Публикация — коммит, положивший текущий текст
* документа (`git log -1` по пути); порядок — его глубина в истории
* (`rev-list --count`: в линейной `dev` потомок глубже предка). Документ,
* который есть в обоих, берётся из `devRef`: документ могли переснять в `dev`
* после Release-коммита. Ночные документы чужой базы
* отсекаются по имени, остальные — `shipCoverage` по строке `base` блока.
*/
export function readRangeDocs({ candidate, base = undefined, devRef = 'origin/dev', cwd } = {}) {
const found = new Map();
for (const ref of [...new Set([candidate, devRef].filter(Boolean))]) {
const listing = git(['ls-tree', '--name-only', ref, '--', `${SHIP_REVIEW_DIR}/`], { allowFailure: true, cwd });
if (listing == null) continue;
for (const path of listing.split('\n').filter(Boolean)) {
const name = docName(path);
const meta = parseDocName(name);
if (meta?.stage !== 'ship') continue;
if (meta.nightly && base !== undefined && meta.tag !== base) continue;
const text = git(['show', `${ref}:${path}`], { allowFailure: true, cwd });
const commit = git(['log', '-1', '--format=%H', ref, '--', path], { allowFailure: true, cwd })?.trim();
if (text == null || !commit) continue;
const order = Number(git(['rev-list', '--count', commit], { cwd }).trim());
found.set(name, { name, text, ref, commit, order });
}
}
return [...found.values()].sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
}
function collect({ tag, candidate, repo }) {
const mode = shipReviewMode({ tag, candidate });
if (!SHA_RE.test(String(candidate))) throw new Error(`invalid candidate SHA: ${candidate}`);
if (!repo) throw new Error('--repo (или GITHUB_REPOSITORY) обязателен');
const { base, commits } = readCandidateHistory(candidate);
const ship = shipIssuesInRange({ commits, issueData: (number) => ghIssue(repo, number) });
const patches = issuePatchSets({ commits, numbers: ship.map((issue) => issue.number) });
for (const issue of ship) issue.patches = patches.get(issue.number);
return { mode, base, ship };
}
if (isMainModule(import.meta.url)) {
try {
const [command, ...rest] = process.argv.slice(2);
const value = (name) => rest.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3) ?? '';
const tag = value('tag');
const repo = value('repo') || process.env.GITHUB_REPOSITORY || '';
const devRef = value('dev-ref') || 'origin/dev';
if (command === 'doc') {
const candidate = value('candidate');
const base = shipReviewMode({ tag, candidate }) === 'nightly' ? readCandidateHistory(candidate).base?.tag : null;
console.log(shipDocPath({ tag, candidate, base }));
} else if (command === 'mode') {
console.log(shipReviewMode({ tag, candidate: value('candidate') }));
} else if (command === 'prepare') {
const candidate = value('candidate');
const out = resolve(value('out') || '.');
const force = value('force') === 'true';
if (shipReviewMode({ tag, candidate }) === 'nightly'
&& git(['merge-base', '--is-ancestor', candidate, devRef], { allowFailure: true }) == null) {
throw new Error(`candidate ${candidate} не предок ${devRef}: ночное ревью читает только код dev`);
}
const { mode, base, ship } = collect({ tag, candidate, repo });
const doc = shipDocPath({ tag, candidate, base: base?.tag ?? null });
const docs = force ? [] : readRangeDocs({ candidate, base: base?.tag ?? null, devRef });
const plan = planShipReview({ tag, ship, docs, base: base?.tag ?? null, force });
const patches = new Map(plan.read.map((issue) => [issue.number, issue.patches]));
mkdirSync(out, { recursive: true });
writeFileSync(join(out, 'ship-issues.json'), `${JSON.stringify({
schema: 1, tag, mode, candidate, base, doc, issues: plan.read, covered: plan.covered, held: plan.held,
}, null, 2)}\n`);
writeFileSync(join(out, 'brief.md'), renderShipBrief({
tag, candidate, base, ship: plan.read, runUrl: value('run-url'), doc, covered: plan.covered, held: plan.held,
}));
console.log(`mode=${mode}`);
console.log(`doc=${doc}`);
console.log(`base=${base ? base.tag : ''}`);
console.log(`issues=${plan.read.map((issue) => issue.number).join(',')}`);
console.log(`patches=${plan.read.length ? formatPatches(patches) : ''}`);
console.log(`subject=${reviewSubject(tag)}`);
if (plan.note) console.log(`note=${plan.note}`);
} else if (command === 'check') {
const candidate = value('candidate');
if (tag === NIGHTLY_TAG) throw new Error('check судит публикацию беты: нужен её тег, а не nightly');
const { base, ship } = collect({ tag, candidate, repo });
const docs = readRangeDocs({ candidate, base: base?.tag ?? null, devRef });
const problems = shipReviewProblems({ tag, ship, docs, base: base?.tag ?? null });
if (!ship.length) console.log('ship-задач в диапазоне нет — пакетное ревью не требуется');
else if (!problems.length) {
const coverage = shipCoverage({ ship, docs, base: base?.tag ?? null });
console.log(`пакетное ревью покрывает ship-задачи: ${coverage.map((item) => `#${item.number} — ${docPath(item.doc)}`).join(', ')}`);
}
for (const problem of problems) console.error(`::error::${problem}`);
process.exit(problems.length ? 1 : 0);
} else if (command === 'comment-high') {
const doc = value('doc');
const high = Number(value('high'));
if (value('mode') !== 'nightly' || !(high > 0)) {
console.log(`комментарий о High не нужен: режим ${value('mode') || '—'}, High ${value('high') || '—'}`);
} else {
if (!repo) throw new Error('--repo (или GITHUB_REPOSITORY) обязателен');
const numbers = value('issues').split(',').filter(Boolean).map(Number);
const issues = numbers.map((number) => ({
number, comments: JSON.parse(gh(['issue', 'view', String(number), '--repo', repo, '--json', 'comments'])).comments,
}));
const targets = highCommentTargets({ doc, issues });
for (const number of targets) gh(['issue', 'comment', String(number), '--repo', repo, '--body', highCommentBody(doc)]);
console.log(targets.length ? `High ${high} в ${doc}: строка в ${targets.map((n) => `#${n}`).join(', ')}`
: `High ${high} в ${doc}: строка во всех задачах документа уже есть`);
}
} else {
throw new Error('usage: ship-review.mjs doc --tag=<tag> [--candidate=<sha>] | mode --tag=<tag> [--candidate=<sha>]'
+ ' | prepare --tag=<tag> --candidate=<sha> --out=<dir> [--force=true] [--repo=o/r] | check --tag=<tag> --candidate=<sha> [--repo=o/r]'
+ ' | comment-high --mode=<m> --high=<N> --doc=<path> --issues=<NN,…> [--repo=o/r]');
}
} catch (error) {
console.error(`::error::${error.message}`);
process.exit(1);
}
}