mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
show/ship stop paying for diff mutants and for every move of dev: - scripts/process-track.mjs resolves the track from the current labels and the diff (show for unlabelled infra, ask for unlabelled product work) and checks the mechanical ship limits; outside them the pipeline comments and relabels track:ship -> track:show in the same round. - Validate on the review material is light on show/ship: a completed push run on the exact SHA is proof, a dispatch asks mutants=false. ask and the ci:mutants label keep the mutant dispatch. - show/ship skip the pre-review rebase when git merge-tree with dev is clean; the candidate is rebased once at merge and still passes Validate before the push to dev. The light merge waits for the push run of the candidate and dispatches only when none appears. - ship inside the limits merges after the light Validate without a model review; the issue gets a machine marker hp:ship-merge. - ship-review.yml + scripts/ship-review.mjs read the code of all ship tasks of a beta range in one model session and publish docs/reviews/SHIP-REVIEW-<tag>.md; both beta publication paths refuse a range with ship tasks the document does not cover or that carries a High. - show reviews judge correctness and AC; the spec review installs neither npm ci nor Chromium, the show review installs Chromium only when the issue names a smoke. Canon: PROCESS.md §5, §5.1, §10.4, new §11.7; REVIEWER.md, AUTHOR.md and AGENTS.md digests. Issue: #696 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
140 lines
7.5 KiB
JavaScript
140 lines
7.5 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Трек задачи и рамки `ship` (PROCESS.md §5, #695/#696).
|
|
*
|
|
* node scripts/process-track.mjs resolve --labels="a,b" --base=<ref> --head=<ref>
|
|
* node scripts/process-track.mjs ship-limits --base=<ref> --head=<ref>
|
|
*
|
|
* `resolve` печатает `track=ship|show|ask` и `mutants=true|false` — то, что
|
|
* конвейер ревью читает, решая, сколько стоит заход: мутанты по диффу нужны
|
|
* только `ask` и метке `ci:mutants`. Инфраструктурная задача без трековой
|
|
* метки — `show` (§5.1); признак инфраструктуры механический, как в §1: в
|
|
* диффе ни одного файла класса A.
|
|
*
|
|
* `ship-limits` печатает `ship=true|false` и по строке `violation=…` на каждое
|
|
* нарушение рамок: дифф `src/**` не больше 30 строк, без новых файлов в
|
|
* `src/**`, без i18n, без полей конфига и без Python. Рамки механические
|
|
* намеренно: по ним конвейер сливает задачу без ревью модели, и решать их
|
|
* «на глаз» некому.
|
|
*/
|
|
import { spawnSync } from 'node:child_process';
|
|
import { appendFileSync } from 'node:fs';
|
|
import { isMainModule } from './spawn-portable.mjs';
|
|
import { classify } from './process-gate.mjs';
|
|
|
|
export const SHIP_SRC_LINE_LIMIT = 30;
|
|
|
|
/**
|
|
* Трек по меткам: `track:*` главнее прежних меток; `trivial` и `small`
|
|
* читаются как `show` (§5.1); задача без трековой метки — `ask`. Метки трека
|
|
* не доказывают продуктовый поток: инфраструктурной задаче владелец тоже может
|
|
* поставить `track:*`, чтобы задать цену конвейера.
|
|
*/
|
|
export function trackFromLabels(labels = []) {
|
|
if (labels.includes('track:ship')) return 'ship';
|
|
if (labels.includes('track:show')) return 'show';
|
|
if (labels.includes('track:ask')) return 'ask';
|
|
if (labels.includes('trivial') || labels.includes('small')) return 'show';
|
|
return 'ask';
|
|
}
|
|
|
|
/** Есть ли у задачи трековая метка вообще — новая или прежняя. */
|
|
export const hasTrackLabel = (labels = []) => ['track:ship', 'track:show', 'track:ask', 'trivial', 'small']
|
|
.some((label) => labels.includes(label));
|
|
|
|
/**
|
|
* Трек, по которому конвейер оценивает заход. Явная метка решает всё; без неё
|
|
* инфраструктурная задача (ни одного файла класса A в диффе) — `show`, прочие —
|
|
* `ask`. Мутанты по диффу — только на `ask` или по метке `ci:mutants`.
|
|
*/
|
|
export function resolveTrack({ labels = [], files = [] } = {}) {
|
|
const infrastructure = files.length > 0 && files.every((file) => classify(file) !== 'A');
|
|
const track = hasTrackLabel(labels) ? trackFromLabels(labels) : (infrastructure ? 'show' : 'ask');
|
|
const mutants = track === 'ask' || labels.includes('ci:mutants');
|
|
return { track, mutants, infrastructure };
|
|
}
|
|
|
|
const I18N = [/^src\/i18n\//, /^custom_components\/[^/]+\/translations\//];
|
|
const CONFIG = [/^src\/types\.ts$/, /^src\/config-[^/]+\.ts$/];
|
|
const PYTHON = /\.py$/;
|
|
|
|
/**
|
|
* Нарушения рамок `ship` по `git diff --numstat` и `--name-status` базы и
|
|
* вершины. Пустой список — задача укладывается в рамки.
|
|
*
|
|
* @param {{added:number|null, deleted:number|null, path:string}[]} numstat
|
|
* @param {{status:string, path:string}[]} nameStatus
|
|
*/
|
|
export function shipLimitViolations({ numstat = [], nameStatus = [] } = {}) {
|
|
const out = [];
|
|
const src = numstat.filter((row) => row.path.startsWith('src/'));
|
|
const binary = src.filter((row) => row.added === null || row.deleted === null);
|
|
const lines = src.reduce((sum, row) => sum + (row.added ?? 0) + (row.deleted ?? 0), 0);
|
|
if (lines > SHIP_SRC_LINE_LIMIT) out.push(`дифф src/** — ${lines} строк при рамке ${SHIP_SRC_LINE_LIMIT}`);
|
|
if (binary.length) out.push(`двоичные файлы в src/**: ${binary.map((row) => row.path).join(', ')}`);
|
|
const added = nameStatus.filter((row) => row.status.startsWith('A') && row.path.startsWith('src/'));
|
|
if (added.length) out.push(`новые файлы в src/**: ${added.map((row) => row.path).join(', ')}`);
|
|
const paths = [...new Set([...numstat, ...nameStatus].map((row) => row.path))];
|
|
const i18n = paths.filter((path) => I18N.some((re) => re.test(path)));
|
|
if (i18n.length) out.push(`ключи i18n: ${i18n.join(', ')}`);
|
|
const config = paths.filter((path) => CONFIG.some((re) => re.test(path)));
|
|
if (config.length) out.push(`поля конфига: ${config.join(', ')}`);
|
|
const python = paths.filter((path) => PYTHON.test(path));
|
|
if (python.length) out.push(`Python: ${python.join(', ')}`);
|
|
return out;
|
|
}
|
|
|
|
/** `git diff --numstat` → строки; двоичный файл даёт `-\t-`. */
|
|
export function parseNumstat(text = '') {
|
|
return String(text).split('\n').map((line) => line.trim()).filter(Boolean).map((line) => {
|
|
const [added, deleted, ...rest] = line.split('\t');
|
|
return { added: added === '-' ? null : Number(added), deleted: deleted === '-' ? null : Number(deleted), path: rest.at(-1) };
|
|
});
|
|
}
|
|
|
|
/** `git diff --name-status` → строки; у переименования путь — новый. */
|
|
export function parseNameStatus(text = '') {
|
|
return String(text).split('\n').map((line) => line.trim()).filter(Boolean).map((line) => {
|
|
const [status, ...paths] = line.split('\t');
|
|
return { status, path: paths.at(-1) };
|
|
});
|
|
}
|
|
|
|
function git(args) {
|
|
const r = spawnSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${(r.stderr || '').trim()}`);
|
|
return r.stdout;
|
|
}
|
|
|
|
if (isMainModule(import.meta.url)) {
|
|
const [command, ...rest] = process.argv.slice(2);
|
|
const value = (name) => rest.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3) ?? '';
|
|
const emit = (lines) => {
|
|
for (const line of lines) console.log(line);
|
|
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`);
|
|
};
|
|
try {
|
|
const base = value('base');
|
|
const head = value('head') || 'HEAD';
|
|
const range = base ? `${base}...${head}` : null;
|
|
if (command === 'resolve') {
|
|
const labels = value('labels').split(',').map((s) => s.trim()).filter(Boolean);
|
|
const files = range ? git(['diff', '--name-only', range]).split('\n').filter(Boolean) : [];
|
|
const { track, mutants } = resolveTrack({ labels, files });
|
|
emit([`track=${track}`, `mutants=${mutants}`]);
|
|
} else if (command === 'ship-limits') {
|
|
if (!range) throw new Error('--base is required');
|
|
const violations = shipLimitViolations({
|
|
numstat: parseNumstat(git(['diff', '--numstat', range])),
|
|
nameStatus: parseNameStatus(git(['diff', '--name-status', range])),
|
|
});
|
|
emit([`ship=${violations.length === 0}`, `violations=${violations.join('; ')}`]);
|
|
} else {
|
|
throw new Error('usage: process-track.mjs resolve --labels=a,b [--base=<ref> --head=<ref>] | ship-limits --base=<ref> [--head=<ref>]');
|
|
}
|
|
} catch (error) {
|
|
console.error(`::error::${error.message}`);
|
|
process.exit(1);
|
|
}
|
|
}
|