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
This commit is contained in:
Claude
2026-10-01 03:25:24 +00:00
committed by claude[bot]
parent af339fda3b
commit e58d7d06f8
10 changed files with 1271 additions and 72 deletions
+10 -1
View File
@@ -88,8 +88,17 @@ export function archivePlan({ names, lines, open, through, addedIn = new Map() }
if (name === INDEX_FILE) continue;
const doc = parseDocName(name);
if (!doc) { kept.push({ name, reason: 'вне схемы имён' }); continue; }
if (doc.nightly && STABLE_TAG_RE.test(doc.tag)) {
// #727: ночной документ со стабильной базой читал код следующей линии —
// ближайшей архивируемой строго новее базы, а не самой базы (она выпущена).
const next = ordered.find((line) => compareStable(line.tag, doc.tag) > 0);
if (next) moves.push({ name, from: `${LIVE_DIR}/${name}`, to: `${ARCHIVE_DIR}/${next.tag}/${name}`, tag: next.tag, issue: null });
else kept.push({ name, reason: `ночное ревью после ${doc.tag}: архивируемой линии новее базы нет` });
continue;
}
if (doc.stage === 'release' || doc.stage === 'ship') {
// #696: пакетное ревью беты уходит в каталог своей стабильной линии.
// #696: пакетное ревью беты уходит в каталог своей стабильной линии;
// #727: ночной документ с базой-бетой — туда же, куда документ этой беты.
const line = doc.tag.replace(/-beta\.\d+$/, '');
if (tags.has(line)) moves.push({ name, from: `${LIVE_DIR}/${name}`, to: `${ARCHIVE_DIR}/${line}/${name}`, tag: line, issue: null });
else kept.push({ name, reason: `ревью линии ${line} не входит в архивируемые линии` });
+8 -4
View File
@@ -29,7 +29,8 @@ export const INDEX_FILE = 'INDEX.md';
const DOC_NAME = /^(CODE|SPEC)-REVIEW-(?:issue-)?(\d+)(?:-r(\d+))?(?:-([a-z0-9-]+))?\.md$/i;
const RELEASE_DOC_NAME = /^RELEASE-REVIEW-(v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))\.md$/i;
// #696: пакетное ревью ship-задач перед бетой (PROCESS.md §11.7) — по тегу беты.
const SHIP_DOC_NAME = /^SHIP-REVIEW-(v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-beta\.(?:0|[1-9]\d*))?)\.md$/i;
// #727: ночной документ `SHIP-REVIEW-<база>-dev-<sha12>.md` — тег здесь база диапазона.
const SHIP_DOC_NAME = /^SHIP-REVIEW-(v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-beta\.(?:0|[1-9]\d*))?)(-dev-[0-9a-f]{12})?\.md$/i;
const COLOUR = {
'зелёный': 'зелёный', 'зеленый': 'зелёный', green: 'зелёный',
'жёлтый': 'жёлтый', 'желтый': 'жёлтый', yellow: 'жёлтый',
@@ -46,7 +47,7 @@ export function parseDocName(name) {
const release = RELEASE_DOC_NAME.exec(String(name));
if (release) return { stage: 'release', issue: null, round: null, suffix: null, tag: release[1] };
const ship = SHIP_DOC_NAME.exec(String(name));
if (ship) return { stage: 'ship', issue: null, round: null, suffix: null, tag: ship[1] };
if (ship) return { stage: 'ship', issue: null, round: null, suffix: null, tag: ship[1], ...(ship[2] ? { nightly: true } : {}) };
const match = DOC_NAME.exec(String(name));
if (!match) return null;
return {
@@ -315,7 +316,9 @@ export function renderIndex({ entries, skipped = [] }) {
const av = tagKey(a.tag);
const bv = tagKey(b.tag);
for (let i = 0; i < 4; i += 1) if (av[i] !== bv[i]) return bv[i] - av[i];
return a.stage.localeCompare(b.stage);
// #727: ночной документ читал код после своей базы — он новее документов этого тега.
if (Boolean(a.nightly) !== Boolean(b.nightly)) return a.nightly ? -1 : 1;
return a.stage.localeCompare(b.stage) || a.name.localeCompare(b.name);
});
const byIssue = new Map();
for (const entry of entries.filter((item) => item.issue != null)) {
@@ -332,7 +335,8 @@ export function renderIndex({ entries, skipped = [] }) {
lines.push('| Issue | Документ | Этап · раунд | Вердикт | H | M | Находки | Файлы |');
lines.push('|---|---|---|---|---:|---:|---|---|');
for (const doc of releaseDocs) {
const [who, what] = doc.stage === 'ship' ? ['бета', 'пакетное ревью ship'] : ['линия', 'ревью линии'];
const [who, what] = doc.nightly ? ['ночь после', 'ночное пакетное ревью ship']
: doc.stage === 'ship' ? ['бета', 'пакетное ревью ship'] : ['линия', 'ревью линии'];
lines.push(`| ${who} ${doc.tag} | [${doc.name}](${doc.name}) | ${what} · — | ${badge(doc.verdict)} ${doc.verdict} | ${doc.high} | ${doc.medium} | ${doc.findings.join('; ').replace(/\|/g, '\\|') || '—'} | ${(doc.files || []).map((f) => `\`${f}\``).join(' ') || '—'} |`);
}
for (const issue of issues) {
+384 -48
View File
@@ -3,8 +3,11 @@
* Пакетное ревью задач `track:ship` перед бетой (#696, PROCESS.md §11.7).
*
* node scripts/ship-review.mjs doc --tag=v1.79.0-beta.1
* node scripts/ship-review.mjs prepare --tag=<тег> --candidate=<sha> --out=<dir> [--repo=owner/name]
* 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. Прочитать её код обязан кто-то до того, как она
@@ -17,23 +20,34 @@
* лишний разбор дешевле пропущенного. Состав диапазона — трейлеры `Issue: #NN`,
* тот же построитель, что у `RELEASE-MEMBERSHIP.json` (#547).
*
* `check` — гейт публикации беты: если в диапазоне есть ship-задачи, документ
* `docs/reviews/SHIP-REVIEW-<тег>.md` обязан быть в кандидате или в `dev`,
* покрывать их все машинным блоком и не нести High.
* `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;
/** Путь документа пакетного ревью для тега беты или стабильного. */
@@ -42,6 +56,38 @@ export function shipReviewDocPath(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));
@@ -100,14 +146,183 @@ export function shipIssuesInRange({ commits = [], issueData }) {
return out;
}
export function renderShipBrief({ tag, candidate, base, ship, runUrl = '' }) {
/** #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 = [
`# Вход пакетного ревью ship ${tag}`,
nightly ? '# Вход ночного пакетного ревью ship' : `# Вход пакетного ревью ship ${tag}`,
'',
`- Кандидат: \`${candidate}\``,
`- Кандидат: \`${candidate}\`${nightly ? ' — голова `dev` после ночного полного Validate' : ''}`,
`- Диапазон: ${base ? `\`${base.tag}\` · \`${base.sha}\`` : 'нет прошлого тега — всё дерево'} .. кандидат`,
...(runUrl ? [`- Прогон: ${runUrl}`] : []),
`- Документ: \`${shipReviewDocPath(tag)}\``,
`- Документ: \`${doc || shipReviewDocPath(tag)}\``,
...(atNight.length ? [`- Прочитаны ночью: ${byDoc(atNight)} — не перечитывать`] : []),
...(before.length ? [`- Прочитаны прежним ревью: ${byDoc(before)} — не перечитывать`] : []),
...(held.length ? [`- High ждёт починки: ${byDoc(held)} — не перечитывать, их держит гейт беты (§11.7)`] : []),
'',
`## Задачи ship (${ship.length}) — слиты без ревью модели`,
'',
@@ -123,8 +338,13 @@ export function renderShipBrief({ tag, candidate, base, ship, runUrl = '' }) {
return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trim()}\n`;
}
/** Машинный блок документа: его пишет публикация, читает `check`. */
export function anchorBlock({ tag, candidate, base = null, issues = [], high = 0, medium = 0, low = 0, runUrl = '' }) {
/**
* Машинный блок документа: его пишет публикация, читает `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,
'### Материал пакетного ревью',
@@ -138,11 +358,17 @@ export function anchorBlock({ tag, candidate, base = null, issues = [], high = 0
`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;
@@ -153,6 +379,7 @@ export function parseAnchorBlock(text = '') {
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,
@@ -160,62 +387,126 @@ export function parseAnchorBlock(text = '') {
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 }) {
export function shipReviewProblems({ tag, ship = [], docText = null, docs = null, base } = {}) {
if (!ship.length) return [];
const doc = shipReviewDocPath(tag);
const numbers = ship.map((issue) => issue.number);
const run = `gh workflow run ship-review.yml --ref dev -f tag=${tag}`;
if (!docText) {
return [`${doc} нет ни в кандидате, ни в dev: ship-задачи ${numbers.map((n) => `#${n}`).join(', ')} слиты без ревью модели, и их код до беты не читал никто (PROCESS.md §11.7). Запустить: ${run}`];
}
const block = parseAnchorBlock(docText);
if (!block) return [`${doc} без машинного блока ${SHIP_REVIEW_ANCHOR}: покрытие задач не доказано. Переснять: ${run} -f force=true`];
const range = docs ?? (docText ? [{ name: docName(doc), text: docText }] : []);
const own = range.find((item) => item.name === docName(doc)) || null;
const problems = [];
if (block.tag !== tag) problems.push(`${doc} записан для тега ${block.tag}, а публикуется ${tag}`);
const missing = numbers.filter((n) => !block.issues.includes(n));
if (missing.length) {
problems.push(`${doc} не покрывает ship-задачи ${missing.map((n) => `#${n}`).join(', ')} — они слиты после ревью. Переснять: ${run} -f force=true`);
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`);
}
if (block.high == null) problems.push(`${doc}: в машинном блоке нет числа High`);
else if (block.high > 0) problems.push(`${doc}: High ${block.high} — бета ждёт починки: находка чинится отдельной задачей, затем ревью переснимается`);
return problems;
}
function git(args, { allowFailure = false } = {}) {
const r = spawnSync('git', args, { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
/** #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 ghIssue(repo, number) {
const r = spawnSync('gh', ['issue', 'view', String(number), '--repo', repo, '--json', 'number,title,body,labels,comments'], {
encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
});
if (r.status !== 0) throw new Error(`gh issue view ${number}: ${(r.stderr || r.stdout || '').trim()}`);
return JSON.parse(r.stdout);
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;
}
/** Документ — из кандидата, иначе из `origin/dev`: ревью могло лечь в dev после Release-коммита. */
export function readShipDoc(tag, candidate) {
const path = shipReviewDocPath(tag);
return git(['show', `${candidate}:${path}`], { allowFailure: true })
?? git(['show', `origin/dev:${path}`], { allowFailure: true });
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 }) {
shipReviewDocPath(tag);
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) });
return { base, ship };
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)) {
@@ -224,28 +515,73 @@ if (isMainModule(import.meta.url)) {
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') {
console.log(shipReviewDocPath(tag));
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 { base, ship } = collect({ tag, candidate, repo });
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, candidate, base, issues: ship }, null, 2)}\n`);
writeFileSync(join(out, 'brief.md'), renderShipBrief({ tag, candidate, base, ship, runUrl: value('run-url') }));
console.log(`doc=${shipReviewDocPath(tag)}`);
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=${ship.map((issue) => issue.number).join(',')}`);
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');
const { ship } = collect({ tag, candidate, repo });
const problems = shipReviewProblems({ tag, ship, docText: readShipDoc(tag, 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) console.log(`пакетное ревью ${shipReviewDocPath(tag)} покрывает ship-задачи ${ship.map((i) => `#${i.number}`).join(', ')}`);
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> | prepare --tag=<tag> --candidate=<sha> --out=<dir> [--repo=o/r] | check --tag=<tag> --candidate=<sha> [--repo=o/r]');
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}`);