mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 21:28:59 +00:00
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:
@@ -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} не входит в архивируемые линии` });
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user