mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 20:29:00 +00:00
feat(process): risk by changed hunks decides ship and informs show (#707)
The ship limits count lines and files but not what was touched: a 12-line pointerdown handler passed them like a typo and merged unread. The track rule also lived twice - the guard computed the cycle limit in bash while process-track.mjs computed the track, and the two disagreed on multiple track labels. The packet still told authors to rebase show/ship branches that merge cleanly. - scripts/change-risk.mjs: one pure classifier over `git diff -U0` from the merge base. Class A lines only; comments, blank lines and pure renames give no risk; deletions do. Area and token rules per class (geometry, touch, migration, devices, perf, ux, visual render/ui), evidence as path:line, five per class. - process-track.mjs: owner confirmation is a comment line "Трек: <x> — решение владельца" by the repo owner (latest wins, only for the current track); several track labels read as the strictest with a warning; cycleLimit, guardLimit and rebaseBeforeReview are the single source. `stage` makes the whole S7 track decision in one call: ship with risk and no confirmation is raised to show with evidence, a confirmed ship keeps merging without the model and records the risk for the batch review; show/ask get a risk note for the reviewer. - _process.yml: the guard asks process-track.mjs for the limit and keeps no track logic; the track step calls the script once and only executes its raise flag and comment file; risk_note reaches the Review prompt, ship_risk reaches the hp:ship-merge comment (marker line unchanged). - task-packet.mjs: track basis, limit and rebase policy; next step without the stale rebase line; risk with its consequence per track; required checks with reasons (ci:golden only on render risk); changelog and visual evidence - from the same exports. - ship-review.mjs: the batch brief prints the risk line of a ship merge. - Canon: PROCESS.md §5, §5.1, §10.4, §11.7, both digests, AGENTS.md. - Registry anchors that watched the moved code are moved, not dropped. Issue: #707 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:
@@ -0,0 +1,220 @@
|
||||
// Риск по изменённым участкам (#707, PROCESS.md §5): одна чистая функция над
|
||||
// `git diff --unified=0 <merge-base>..<head>`.
|
||||
//
|
||||
// Трек `ship`/`show` держится на «решать нечего», а рамки ship считают строки
|
||||
// и файлы, но не видят, ЧТО тронуто: обработчик `pointerdown` в двенадцать
|
||||
// строк проходил их так же, как опечатка. Здесь судится изменённая строка, а не
|
||||
// файл: правило «участок» — любая изменённая строка кода в файле из списка,
|
||||
// правило «токен» — токен в изменённой строке любого файла класса A (§1).
|
||||
// Монолиты (`houseplan-card.ts`, `houseplan-editor-runtime.ts`) судятся только
|
||||
// токенами: ни один шаблон участка их не задевает, и тест держит это
|
||||
// («монолит участком не судится»).
|
||||
//
|
||||
// Таблица — эвристика (ТЗ #707 §10 п.2): пути и токены меняются свободно,
|
||||
// каждая строка покрыта положительным и отрицательным случаем в
|
||||
// `test/process-track.test.mjs`. Ложное срабатывание стоит одного ревью модели,
|
||||
// пропуск — не хуже, чем без проверки.
|
||||
import { classify } from './change-classes.mjs';
|
||||
|
||||
/** Классы в порядке печати. `visual` — единственный, что ship не повышает. */
|
||||
export const RISK_CLASSES = Object.freeze(['geometry', 'touch', 'migration', 'devices', 'perf', 'ux', 'visual']);
|
||||
export const RAISING_CLASSES = Object.freeze(RISK_CLASSES.filter((cls) => cls !== 'visual'));
|
||||
/** На класс печатается не больше стольких доказательств; классифицируются все ханки. */
|
||||
export const RISK_EVIDENCE_LIMIT = 5;
|
||||
|
||||
const escape = (text) => text.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
||||
/** `wall-*` → `src/wall-<что угодно>.ts` верхнего уровня `src/`. */
|
||||
const src = (glob) => ({ label: glob, re: new RegExp(`^src/${escape(glob).replace(/\*/g, '[^/]*')}\\.ts$`) });
|
||||
const py = (stem) => ({ label: `${stem}.py`, re: new RegExp(`^custom_components/houseplan/${stem}\\.py$`) });
|
||||
const path = (label, re) => ({ label, re });
|
||||
|
||||
const RENDER_DIR = path('src/render/**', /^src\/render\//);
|
||||
|
||||
/** Правило «участок»: класс → файлы, любая изменённая строка кода которых даёт класс. */
|
||||
const AREAS = {
|
||||
geometry: [
|
||||
...['physical-geometry', 'space-geometry', 'wall-*', 'junction-limits', 'coincident-partitions',
|
||||
'coordinate-canonicalization', 'opening-*', 'partition-openings', 'open-spans', 'near-axis', 'align-grid',
|
||||
'grid-scale', 'room-fit', 'resize*', 'stairs*', 'radar-geometry', 'zigbee-topology-geometry',
|
||||
'device-marker-geometry', 'plan-geometry-preflight', 'plan-optimizer', 'zero-walls', 'iso-projection'].map(src),
|
||||
...['geometry_migration', 'coordinate_canonicalization', 'junction_limits', 'wall_segment_model',
|
||||
'radar_geometry', 'projection'].map(py),
|
||||
],
|
||||
touch: ['pointer-modality', 'pointer-move-queue', 'touch-gesture-click-guard', 'live-interaction-runtime',
|
||||
'live-viewport', 'viewport-transition', 'room-gear-drag'].map(src),
|
||||
migration: [
|
||||
...['types', 'config-*', 'wall-tool-compat', 'config-adoption', 'config-store'].map(src),
|
||||
...['store', 'geometry_migration', 'import_export', 'validation'].map(py),
|
||||
],
|
||||
devices: [
|
||||
...['device-toggle', 'marker-toggle-entity', 'integration-provider', 'virtual-light-state', 'vacuum*',
|
||||
'device-hit-owner'].map(src),
|
||||
...['auth', 'http_api', 'websocket_api', 'virtual_lights', 'vacuum_routes'].map(py),
|
||||
],
|
||||
perf: [RENDER_DIR, ...['render-*', 'houseplan-render-lifecycle', 'iso-scene-render', 'glow-*', 'day-cycle-render',
|
||||
'initial-load', 'boot-soft-layout'].map(src)],
|
||||
'visual:render': [RENDER_DIR, ...['iso-*', 'glow-*', 'day-cycle-render', 'space-render', 'stairs-view',
|
||||
'device-visual', 'device-face'].map(src), path('*.generated.ts', /^src\/(?:.*\/)?[^/]*\.generated\.ts$/)],
|
||||
'visual:ui': [
|
||||
path('src/styles/**', /^src\/styles\//),
|
||||
path('src/styles.ts', /^src\/styles\.ts$/),
|
||||
path('src/**/*styles*.ts', /^src\/(?:.*\/)?[^/]*styles[^/]*\.ts$/),
|
||||
path('src/**/*-style.ts', /^src\/(?:.*\/)?[^/]*-style\.ts$/),
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Правило «токен»: класс → регулярное выражение по изменённой строке файла
|
||||
* класса A (кроме JSON — у переводов своё правило `ux`: текст перевода со словом
|
||||
* «thickness» не геометрия). `snapshot` не `snapTo*`: геометрия — только привязка.
|
||||
*/
|
||||
const TOKENS = {
|
||||
geometry: /[\w$]*(?:thickness|canonicaliz|junction|snap(?:to|pt))[\w$]*/gi,
|
||||
touch: /(?:pointer(?:down|up|move|cancel)|touch(?:start|end|move|cancel))(?![a-z])|PointerEvent|TouchEvent|(?:set|release)PointerCapture|pointerType|touch-action|dblclick|contextmenu/g,
|
||||
migration: /[\w$]*migrat[\w$]*|STORAGE_VERSION|schema_version/gi,
|
||||
devices: /callService|turn_on|turn_off|is_admin|require_admin/g,
|
||||
perf: /requestAnimationFrame|(?:Resize|Intersection|Mutation)Observer|getBoundingClientRect|getComputedStyle|offset(?:Width|Height|Top|Left)|will-change|backdrop-filter|(?<![\w$])filter:/g,
|
||||
// SVG-атрибут с `=` или `:` — разметкой, объектом стиля или CSS.
|
||||
'visual:render': /(?<![\w$-])(?:stroke(?:-(?:width|dasharray|linecap|linejoin))?|fill(?:-opacity)?|opacity|viewBox|shape-rendering|vector-effect)(?=['"]?\s*[=:])/g,
|
||||
};
|
||||
|
||||
/** Правило `ux`: только добавленные строки. */
|
||||
const I18N_FILE = [/^src\/i18n\/(?:.*\/)?[^/]+\.json$/, /^custom_components\/(?:.*\/)?translations\/[^/]+\.json$/];
|
||||
const JSON_KEY = /^\s*"((?:[^"\\]|\\.)+)"\s*:/;
|
||||
const DEFINE = 'customElements.define(';
|
||||
|
||||
/** Пустая строка или строка-комментарий риска не даёт. */
|
||||
export function isCommentOrBlank(text, file = '') {
|
||||
const line = String(text).trim();
|
||||
if (!line) return true;
|
||||
if (/^(?:\/\/|\/\*|\*\/|\*|<!--)/.test(line)) return true;
|
||||
return file.endsWith('.py') && line.startsWith('#');
|
||||
}
|
||||
|
||||
/**
|
||||
* Разбор `git diff --unified=0` (подходит и с контекстом): файлы и их изменённые
|
||||
* строки с номерами. Удалённая строка несёт номер и путь старой стороны,
|
||||
* добавленная — новой. Переименование без правки ханков не даёт, двоичный файл —
|
||||
* тоже (его ловят рамки ship).
|
||||
*/
|
||||
export function parseUnifiedDiff(text = '') {
|
||||
const files = [];
|
||||
let file = null;
|
||||
let oldLine = 0; let newLine = 0; let oldLeft = 0; let newLeft = 0;
|
||||
const unquote = (p) => (p.startsWith('"') && p.endsWith('"') ? p.slice(1, -1) : p);
|
||||
for (const raw of String(text).split('\n')) {
|
||||
if (file && (oldLeft > 0 || newLeft > 0)) {
|
||||
if (raw.startsWith('-') && oldLeft > 0) {
|
||||
file.lines.push({ side: '-', line: oldLine, text: raw.slice(1) }); oldLine += 1; oldLeft -= 1; continue;
|
||||
}
|
||||
if (raw.startsWith('+') && newLeft > 0) {
|
||||
file.lines.push({ side: '+', line: newLine, text: raw.slice(1) }); newLine += 1; newLeft -= 1; continue;
|
||||
}
|
||||
if (raw.startsWith(' ')) { oldLine += 1; newLine += 1; oldLeft -= 1; newLeft -= 1; continue; }
|
||||
if (raw.startsWith('\\')) continue;
|
||||
oldLeft = 0; newLeft = 0;
|
||||
}
|
||||
let m;
|
||||
if ((m = /^diff --git (?:"?a\/)(.+?)"? (?:"?b\/)(.+?)"?$/.exec(raw))) {
|
||||
file = { oldPath: m[1], newPath: m[2], lines: [] };
|
||||
files.push(file);
|
||||
continue;
|
||||
}
|
||||
if (!file) continue;
|
||||
if ((m = /^--- (.+)$/.exec(raw))) { file.oldPath = m[1] === '/dev/null' ? null : unquote(m[1]).replace(/^a\//, ''); continue; }
|
||||
if ((m = /^\+\+\+ (.+)$/.exec(raw))) { file.newPath = m[1] === '/dev/null' ? null : unquote(m[1]).replace(/^b\//, ''); continue; }
|
||||
if ((m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(raw))) {
|
||||
oldLine = Number(m[1]); oldLeft = m[2] === undefined ? 1 : Number(m[2]);
|
||||
newLine = Number(m[3]); newLeft = m[4] === undefined ? 1 : Number(m[4]);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/** Риск диффа без единого класса: этап spec, ветки нет, инфраструктура. */
|
||||
export const emptyRisk = () => ({ classes: [], raising: [], visual: { render: false, ui: false }, evidence: {}, counts: {} });
|
||||
|
||||
/**
|
||||
* Классы риска диффа с доказательствами `путь:строка · правило`.
|
||||
* `raising` — классы, которые повышают ship (все, кроме `visual`);
|
||||
* `visual.render` / `visual.ui` — область визуального риска; `evidence` — не
|
||||
* больше `RISK_EVIDENCE_LIMIT` строк на класс, `counts` — сколько всего.
|
||||
*/
|
||||
export function classifyRisk(diffText = '') {
|
||||
// класс → ключ строки → { path, line, side, rules }
|
||||
const hits = new Map();
|
||||
const add = (cls, where, rule) => {
|
||||
if (!hits.has(cls)) hits.set(cls, new Map());
|
||||
const key = `${where.side}${where.path}:${where.line}`;
|
||||
const entry = hits.get(cls).get(key) || { ...where, rules: [] };
|
||||
if (!entry.rules.includes(rule)) entry.rules.push(rule);
|
||||
hits.get(cls).set(key, entry);
|
||||
};
|
||||
const areas = { render: false, ui: false };
|
||||
for (const file of parseUnifiedDiff(diffText)) {
|
||||
const removedKeys = new Set();
|
||||
for (const row of file.lines) {
|
||||
const p = row.side === '-' ? file.oldPath : file.newPath;
|
||||
if (row.side !== '-' || !p || !I18N_FILE.some((re) => re.test(p))) continue;
|
||||
const key = JSON_KEY.exec(row.text);
|
||||
if (key) removedKeys.add(key[1]);
|
||||
}
|
||||
for (const row of file.lines) {
|
||||
const p = row.side === '-' ? file.oldPath : file.newPath;
|
||||
if (!p || classify(p) !== 'A' || isCommentOrBlank(row.text, p)) continue;
|
||||
const where = { path: p, line: row.line, side: row.side };
|
||||
for (const [cls, rules] of Object.entries(AREAS)) {
|
||||
const rule = rules.find((r) => r.re.test(p));
|
||||
if (rule) add(cls, where, `участок ${rule.label}`);
|
||||
}
|
||||
if (!p.endsWith('.json')) {
|
||||
for (const [cls, re] of Object.entries(TOKENS)) {
|
||||
re.lastIndex = 0;
|
||||
const found = [...new Set([...row.text.matchAll(re)].map((m) => m[0]))];
|
||||
for (const token of found) add(cls, where, `токен ${token}`);
|
||||
}
|
||||
}
|
||||
if (row.side === '+') {
|
||||
if (I18N_FILE.some((re) => re.test(p))) {
|
||||
const key = JSON_KEY.exec(row.text);
|
||||
if (key && !removedKeys.has(key[1])) add('ux', where, `новый ключ "${key[1]}"`);
|
||||
}
|
||||
if (row.text.includes(DEFINE)) add('ux', where, `токен ${DEFINE}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const risk = emptyRisk();
|
||||
const merged = new Map();
|
||||
for (const [cls, entries] of hits) {
|
||||
const name = cls.startsWith('visual:') ? 'visual' : cls;
|
||||
if (cls === 'visual:render') areas.render = true;
|
||||
if (cls === 'visual:ui') areas.ui = true;
|
||||
if (!merged.has(name)) merged.set(name, new Map());
|
||||
for (const [key, entry] of entries) {
|
||||
const into = merged.get(name).get(key) || { ...entry, rules: [] };
|
||||
const area = cls.startsWith('visual:') ? ` (${cls.slice('visual:'.length)})` : '';
|
||||
for (const rule of entry.rules) if (!into.rules.includes(`${rule}${area}`)) into.rules.push(`${rule}${area}`);
|
||||
merged.get(name).set(key, into);
|
||||
}
|
||||
}
|
||||
for (const cls of RISK_CLASSES) {
|
||||
const entries = merged.get(cls);
|
||||
if (!entries?.size) continue;
|
||||
risk.classes.push(cls);
|
||||
const list = [...entries.values()];
|
||||
risk.counts[cls] = list.length;
|
||||
risk.evidence[cls] = list.slice(0, RISK_EVIDENCE_LIMIT).map((e) => `${e.path}:${e.line}${e.side === '-' ? ' (удалена)' : ''} · ${e.rules.join(', ')}`);
|
||||
}
|
||||
risk.raising = risk.classes.filter((cls) => RAISING_CLASSES.includes(cls));
|
||||
risk.visual = areas;
|
||||
return risk;
|
||||
}
|
||||
|
||||
/** Строка класса для комментария, промпта и пакета: `touch: a:1 · …; b:2 · … и ещё N`. */
|
||||
export function riskClassLine(risk, cls) {
|
||||
const evidence = risk?.evidence?.[cls] || [];
|
||||
const more = (risk?.counts?.[cls] ?? evidence.length) - evidence.length;
|
||||
const area = cls === 'visual'
|
||||
? ` (${[risk.visual?.render && 'render', risk.visual?.ui && 'ui'].filter(Boolean).join(', ')})` : '';
|
||||
return `${cls}${area}: ${evidence.join('; ')}${more > 0 ? `; и ещё ${more}` : ''}`;
|
||||
}
|
||||
@@ -13639,27 +13639,29 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: ' if True: # mutant: concurrent flushes are not serialised\n',
|
||||
}],
|
||||
},
|
||||
// r1 #695: инфраструктура без трековой метки — трек show (§5.1).
|
||||
// r1 #695: инфраструктура без трековой метки — трек show (§5.1). С #707 трек и
|
||||
// лимит guard берёт из process-track.mjs: якорь переехал туда, свидетель
|
||||
// исполняет шаг guard настоящим bash.
|
||||
{
|
||||
id: 'guard-infra-keeps-ask-limit',
|
||||
guard: 'node --test --test-name-pattern="r1 #695" test/review-doc-guard.test.mjs',
|
||||
because: 'r1 #695: an infrastructure task without a track label reads as track:show (PROCESS §5.1); '
|
||||
+ 'the guard must give it the show cycle limit 2, not the ask limit 4',
|
||||
+ 'the guard must give it the show cycle limit 2, not the ask limit 4 (#707: one rule in process-track.mjs)',
|
||||
patches: [{
|
||||
file: '.github/workflows/_process.yml',
|
||||
find: ' limit=2\n echo "инфраструктурная задача без трековой метки',
|
||||
replace: ' limit=4 # mutant: infra keeps the ask limit\n echo "инфраструктурная задача без трековой метки',
|
||||
file: 'scripts/process-track.mjs',
|
||||
find: "(hasTrackLabel(labels) ? trackFromLabels(labels) : (infrastructure ? 'show' : 'ask'));",
|
||||
replace: "(hasTrackLabel(labels) ? trackFromLabels(labels) : (infrastructure ? 'ask' : 'ask')); // mutant: infra keeps the ask limit",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'packet-infra-track-ignores-show-default',
|
||||
guard: 'node --test --test-name-pattern="r1 #695" test/task-packet.test.mjs',
|
||||
because: 'r1 #695: the packet names the track an infrastructure task actually runs on — show '
|
||||
+ 'without a label, the owner label otherwise',
|
||||
+ 'without a label, the owner label otherwise (#707: via trackOrigin, shared with the pipeline)',
|
||||
patches: [{
|
||||
file: 'scripts/task-packet.mjs',
|
||||
find: " const infraTrack = hasTrackLabel(labels) ? trackFromLabels(labels) : 'show';",
|
||||
replace: ' const infraTrack = trackFromLabels(labels); // mutant: unlabelled infra reads as ask',
|
||||
find: ' const origin = trackOrigin({ labels, comments, owner, infrastructure: infrastructure || infrastructureHint });',
|
||||
replace: ' const origin = trackOrigin({ labels, comments, owner, infrastructure: false }); // mutant: unlabelled infra reads as ask',
|
||||
}],
|
||||
},
|
||||
// #696: цена захода по треку — мутанты, рамки ship, пакетное ревью перед бетой.
|
||||
@@ -13696,14 +13698,15 @@ const MUTANT_DEFINITIONS = [
|
||||
}],
|
||||
},
|
||||
{
|
||||
// #707: решение по ship на S7 переехало из bash шага трека в decideTrack.
|
||||
id: 'pipeline-ship-ignores-limits',
|
||||
guard: 'node --test --test-name-pattern="трек снимается до ребейза" test/process-track.test.mjs',
|
||||
guard: 'node --test --test-name-pattern="#707 AC3" test/process-track.test.mjs',
|
||||
because: '#696: ship skips the model only inside the limits; ignoring the limits merges '
|
||||
+ 'unread code of any size',
|
||||
+ 'unread code of any size — even when the owner confirmed the ship track (#707)',
|
||||
patches: [{
|
||||
file: '.github/workflows/_process.yml',
|
||||
find: " if printf '%s\\n' \"$limits\" | grep -qx 'ship=true'; then",
|
||||
replace: ' if true; then # mutant: ship limits ignored',
|
||||
file: 'scripts/process-track.mjs',
|
||||
find: ' if (violations.length || riskRaises) {',
|
||||
replace: ' if (riskRaises) { // mutant: ship limits ignored',
|
||||
}],
|
||||
},
|
||||
{
|
||||
|
||||
+302
-35
@@ -1,42 +1,65 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Трек задачи и рамки `ship` (PROCESS.md §5, #695/#696).
|
||||
* Трек задачи, его основание, лимит циклов и рамки `ship` (PROCESS.md §5,
|
||||
* #695/#696/#707) — одно правило на конвейер и пакет задачи.
|
||||
*
|
||||
* node scripts/process-track.mjs stage --stage=code|spec --labels="a,b" --branch=<имя> \
|
||||
* --base=origin/dev --head=HEAD --comments=<json> --owner=<login> --out=<dir> [--run-url=<url>]
|
||||
* node scripts/process-track.mjs limit --labels="a,b" [--files=<список путей>]
|
||||
* 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` и
|
||||
* `full=true|false` — то, что конвейер ревью читает, решая, сколько стоит
|
||||
* заход: мутантов в разработке нет ни на одном треке (#709) — `mutants`
|
||||
* всегда `false`, весь реестр проверяет только ночной прогон; полный
|
||||
* набор (смоки, golden, perf) на ветке задачи — только меткам `ci:full` и
|
||||
* `ci:golden` (#697). Инфраструктурная задача без трековой
|
||||
* метки — `show` (§5.1); признак инфраструктуры механический, как в §1: в
|
||||
* диффе ни одного файла класса A.
|
||||
* `stage` — шаг «Трек задачи и рамки ship» конвейера: ОДИН вызов решает трек,
|
||||
* его основание, рамки ship, риск по изменённым участкам (#707), повышение
|
||||
* ship → show, заметку риска для промпта ревью и строку риска для комментария
|
||||
* слияния ship. Печатает `track=`, `mutants=`, `full=`, `ship=`, `raise=`,
|
||||
* `basis=`, `risk=`; те же поля и многострочные `risk_note`/`ship_risk` пишет в
|
||||
* `$GITHUB_OUTPUT`; при `raise=true` кладёт тело комментария в `<out>/raise.md`.
|
||||
* Bash шага только исполняет: логики трека в нём нет.
|
||||
*
|
||||
* `ship-limits` печатает `ship=true|false` и по строке `violation=…` на каждое
|
||||
* нарушение рамок: дифф `src/**` не больше 30 строк, без новых файлов в
|
||||
* `src/**`, без i18n, без полей конфига и без Python. Рамки механические
|
||||
* намеренно: по ним конвейер сливает задачу без ревью модели, и решать их
|
||||
* «на глаз» некому.
|
||||
* `limit` — job `guard`: трек и лимит циклов по меткам и списку файлов из
|
||||
* compare API; ответ на 300 файлов и больше инфраструктуру не доказывает.
|
||||
*
|
||||
* Мутантов в разработке нет ни на одном треке (#709) — `mutants` всегда
|
||||
* `false`. Полный набор (смоки, golden, perf) на ветке задачи — только меткам
|
||||
* `ci:full` и `ci:golden` (#697): риск, включая `visual`, его не заказывает.
|
||||
* Рамки ship механические намеренно: по ним конвейер сливает задачу без ревью
|
||||
* модели, и решать их «на глаз» некому.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { appendFileSync } from 'node:fs';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
import { classify } from './process-gate.mjs';
|
||||
import { classify } from './change-classes.mjs';
|
||||
import { classifyRisk, emptyRisk, riskClassLine, RISK_CLASSES, RAISING_CLASSES } from './change-risk.mjs';
|
||||
|
||||
// Классификатор риска живёт рядом (ТЗ #707 §10 п.5); пакет и конвейер берут его отсюда.
|
||||
export { classifyRisk, emptyRisk, riskClassLine, RISK_CLASSES, RAISING_CLASSES };
|
||||
|
||||
export const SHIP_SRC_LINE_LIMIT = 30;
|
||||
/** Потолок ответа compare API: полный список файлов при нём не доказан. */
|
||||
export const COMPARE_FILES_CAP = 300;
|
||||
/** Не больше стольких строк заметки риска в промпте ревью. */
|
||||
export const RISK_NOTE_LINE_LIMIT = 25;
|
||||
|
||||
/** Трековые метки от строжайшей к мягкой. */
|
||||
const STRICTEST_FIRST = ['ask', 'show', 'ship'];
|
||||
|
||||
/** Явные трековые метки задачи, строжайшая первой. */
|
||||
export const explicitTracks = (labels = []) => STRICTEST_FIRST.filter((track) => labels.includes(`track:${track}`));
|
||||
|
||||
/**
|
||||
* Трек по меткам: `track:*` главнее прежних меток; `trivial` и `small`
|
||||
* читаются как `show` (§5.1); задача без трековой метки — `ask`. Метки трека
|
||||
* не доказывают продуктовый поток: инфраструктурной задаче владелец тоже может
|
||||
* Трек по меткам: `track:*` главнее прежних меток, из нескольких `track:*`
|
||||
* действует строжайшая (`ask` > `show` > `ship`) — это дефект разметки, и
|
||||
* решать его в пользу дешёвого трека нельзя (#707). `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';
|
||||
const [strictest] = explicitTracks(labels);
|
||||
if (strictest) return strictest;
|
||||
if (labels.includes('trivial') || labels.includes('small')) return 'show';
|
||||
return 'ask';
|
||||
}
|
||||
@@ -46,21 +69,121 @@ export const hasTrackLabel = (labels = []) => ['track:ship', 'track:show', 'trac
|
||||
.some((label) => labels.includes(label));
|
||||
|
||||
/**
|
||||
* Трек, по которому конвейер оценивает заход. Явная метка решает всё; без неё
|
||||
* инфраструктурная задача (ни одного файла класса A в диффе) — `show`, прочие —
|
||||
* `ask`. Мутанты проверяют тесты, а не продукт: в разработке их не гоняют ни
|
||||
* локально, ни в CI (#709, решение владельца 2026-09-29) — только ночной полный
|
||||
* реестр (`mutation-gate.yml`, #513). Поле остаётся для совместимости выхода.
|
||||
* Полный набор — по меткам `ci:full` и `ci:golden` на любом треке (#697).
|
||||
* Трек по меткам и признаку инфраструктуры: явная метка решает всё; без неё
|
||||
* инфраструктурная задача (§1) — `show`, прочие — `ask` (§5.1). Одна функция на
|
||||
* guard, шаг трека и пакет задачи.
|
||||
*/
|
||||
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');
|
||||
export const labelTrack = ({ labels = [], infrastructure = false } = {}) => (hasTrackLabel(labels) ? trackFromLabels(labels) : (infrastructure ? 'show' : 'ask'));
|
||||
|
||||
/**
|
||||
* Трек, по которому конвейер оценивает заход. Признак инфраструктуры
|
||||
* механический, как в §1: в диффе ни одного файла класса A; обрезанный список
|
||||
* (`filesCapped`) его не доказывает. Мутанты проверяют тесты, а не продукт: в
|
||||
* разработке их не гоняют ни локально, ни в CI (#709, решение владельца
|
||||
* 2026-09-29) — только ночной полный реестр (`mutation-gate.yml`, #513). Поле
|
||||
* остаётся для совместимости выхода. Полный набор — по меткам `ci:full` и
|
||||
* `ci:golden` на любом треке (#697).
|
||||
*/
|
||||
export function resolveTrack({ labels = [], files = [], filesCapped = false } = {}) {
|
||||
const infrastructure = !filesCapped && files.length > 0 && files.every((file) => classify(file) !== 'A');
|
||||
const track = labelTrack({ labels, infrastructure });
|
||||
const mutants = false;
|
||||
const full = labels.includes('ci:full') || labels.includes('ci:golden');
|
||||
return { track, mutants, full, infrastructure };
|
||||
}
|
||||
|
||||
/** Лимит циклов код-ревью (§4, §5): `ask` — 4, `show`/`ship` — 2. */
|
||||
export const cycleLimit = (track) => (track === 'ask' ? 4 : 2);
|
||||
|
||||
/**
|
||||
* Трек и лимит для job `guard`: пути — из compare API, у которого потолок
|
||||
* ответа 300 файлов; такой ответ инфраструктуру не доказывает — остаётся `ask`/4.
|
||||
*/
|
||||
export function guardLimit({ labels = [], files = [] } = {}) {
|
||||
const { track, infrastructure } = resolveTrack({ labels, files, filesCapped: files.length >= COMPARE_FILES_CAP });
|
||||
return { track, limit: cycleLimit(track), infrastructure };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ребейз до ревью (§10.4, #257, #696): `ask` приводится к `dev` всегда;
|
||||
* `show`/`ship` — только если слияние с `dev` не чистое (`mergeClean !== true`:
|
||||
* неизвестная чистота — тоже ребейз, как у шага «Привести ветку к dev»).
|
||||
*/
|
||||
export const rebaseBeforeReview = (track, mergeClean) => track === 'ask' || mergeClean !== true;
|
||||
|
||||
/**
|
||||
* Строка подтверждения трека владельцем (§5, #707): в начале строки
|
||||
* комментария, тире любое из «—», «–», «-», слова без учёта регистра. Агент её не
|
||||
* пишет никогда: агенты и конвейер действуют от учётной записи владельца, и по
|
||||
* автору события их не отличить (ТЗ #707 §10 п.1).
|
||||
*/
|
||||
export const OWNER_TRACK_LINE = /^[ \t]*трек:[ \t]*(ship|show|ask)[ \t]*[—–-][ \t]*решение владельца/gimu;
|
||||
|
||||
/** Комментарии из `gh issue view --json comments` (или массива) в общий вид. */
|
||||
export function normalizeComments(data) {
|
||||
const list = Array.isArray(data) ? data : data?.comments;
|
||||
if (!Array.isArray(list)) return null;
|
||||
return list.map((c) => ({
|
||||
author: typeof c?.author === 'string' ? c.author : (c?.author?.login ?? null),
|
||||
body: String(c?.body ?? ''),
|
||||
createdAt: c?.createdAt ?? null,
|
||||
url: c?.url ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Самая поздняя строка владельца `Трек: <x> — решение владельца` по времени комментария. */
|
||||
export function ownerTrackLine(comments, owner) {
|
||||
if (!Array.isArray(comments) || !owner) return null;
|
||||
const who = String(owner).toLowerCase();
|
||||
const sorted = comments.map((c, i) => ({ c, i })).sort((a, b) => {
|
||||
const x = String(a.c?.createdAt ?? ''); const y = String(b.c?.createdAt ?? '');
|
||||
return x < y ? -1 : x > y ? 1 : a.i - b.i;
|
||||
});
|
||||
let latest = null;
|
||||
for (const { c } of sorted) {
|
||||
const author = typeof c?.author === 'string' ? c.author : c?.author?.login;
|
||||
if (String(author ?? '').toLowerCase() !== who) continue;
|
||||
for (const m of String(c?.body ?? '').matchAll(OWNER_TRACK_LINE)) {
|
||||
latest = { track: m[1].toLowerCase(), at: c.createdAt ?? null, url: c.url ?? null };
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Трек и его основание (К2 #707). Метка без строки владельца — предложение
|
||||
* (аналитика, агента, конвейера); подтверждает самая поздняя строка владельца,
|
||||
* и только если её трек равен текущему. Комментарии недоступны (`null`) —
|
||||
* «происхождение не установлено», подтверждения нет. Несколько трековых меток —
|
||||
* строжайшая и предупреждение в основании.
|
||||
*/
|
||||
export function trackOrigin({ labels = [], comments = [], owner = '', infrastructure = false } = {}) {
|
||||
const explicit = explicitTracks(labels);
|
||||
const track = labelTrack({ labels, infrastructure });
|
||||
const warning = explicit.length > 1
|
||||
? `несколько трековых меток (${explicit.map((t) => `track:${t}`).join(', ')}) — дефект разметки, действует строжайшая track:${track}`
|
||||
: null;
|
||||
let origin; let basis; let confirmed = false; let confirmedAt = null;
|
||||
if (explicit.length) {
|
||||
if (!Array.isArray(comments) || !owner) {
|
||||
origin = 'unknown'; basis = 'метка, происхождение не установлено (комментарии недоступны)';
|
||||
} else {
|
||||
const line = ownerTrackLine(comments, owner);
|
||||
if (line && line.track === track) {
|
||||
origin = 'owner'; confirmed = true; confirmedAt = line.at;
|
||||
basis = `метка, подтверждённая владельцем (${String(line.at ?? '').slice(0, 10) || 'дата не записана'})`;
|
||||
} else {
|
||||
origin = 'proposal'; basis = 'метка без подтверждения — предложение';
|
||||
}
|
||||
}
|
||||
} else if (labels.includes('small') || labels.includes('trivial')) {
|
||||
origin = 'legacy'; basis = `прежняя метка ${labels.includes('small') ? 'small' : 'trivial'} → show (§5.1)`;
|
||||
} else {
|
||||
origin = 'default'; basis = infrastructure ? 'метки нет: инфраструктура → show' : 'метки нет: продукт → ask';
|
||||
}
|
||||
return { track, basis, origin, confirmed, confirmedAt, warning, explicit };
|
||||
}
|
||||
|
||||
const I18N = [/^src\/i18n\//, /^custom_components\/[^/]+\/translations\//];
|
||||
const CONFIG = [/^src\/types\.ts$/, /^src\/config-[^/]+\.ts$/];
|
||||
const PYTHON = /\.py$/;
|
||||
@@ -107,15 +230,117 @@ export function parseNameStatus(text = '') {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Заметка риска для промпта ревью `show`/`ask` (К3 #707), не длиннее
|
||||
* `RISK_NOTE_LINE_LIMIT` строк; пустой риск — пустая строка. Риск — вопрос
|
||||
* ревьюеру, маршрут не меняется: автоматического show → ask нет.
|
||||
*/
|
||||
export function riskNote({ track, confirmed = false, risk, labels = [] } = {}) {
|
||||
if (!risk?.classes?.length) return '';
|
||||
const lines = [];
|
||||
const raising = risk.raising || [];
|
||||
if (raising.length && (track === 'show' || track === 'ask')) {
|
||||
if (track === 'show' && !confirmed) {
|
||||
lines.push('**Риск по изменённым участкам (#707).** Трек show держится на «решать нечего» (PROCESS.md §5). По каждому классу ниже назови документ или AC, где поведение уже зафиксировано; не нашёл — Medium «решать есть что — нужен track:ask» с названным критерием §5.');
|
||||
} else if (track === 'show') {
|
||||
lines.push('**Риск по изменённым участкам (#707).** Трек show подтверждён владельцем. По каждому классу ниже назови документ или AC, где поведение уже зафиксировано; трек не повышать: не нашёл — вопрос владельцу в комментарии, вариант по умолчанию «повысить до ask».');
|
||||
} else {
|
||||
lines.push('**Риск по изменённым участкам (#707).** Трек ask: сверь, что каждый класс ниже покрыт AC ТЗ.');
|
||||
}
|
||||
for (const cls of raising) lines.push(`- ${riskClassLine(risk, cls)}`);
|
||||
}
|
||||
if (risk.visual?.render && !labels.includes('ci:golden')) {
|
||||
lines.push('Визуальный риск в пути отрисовки плана без ci:golden — если задача меняет вид, нужен ci:golden (PROCESS.md §8); иначе запиши в «чего не проверял».');
|
||||
lines.push(`- ${riskClassLine(risk, 'visual')}`);
|
||||
}
|
||||
return lines.slice(0, RISK_NOTE_LINE_LIMIT).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Строка риска для комментария слияния ship (`hp:ship-merge`): человеку и
|
||||
* машинная `<!-- hp:ship-risk classes=… -->` для пакетного ревью (§11.7).
|
||||
* Без риска — пустая строка.
|
||||
*/
|
||||
export function shipRiskText({ risk, confirmed = false } = {}) {
|
||||
if (!risk?.classes?.length) return '';
|
||||
const head = confirmed && risk.raising?.length
|
||||
? 'Риск по участкам (трек подтверждён владельцем, не повышен)'
|
||||
: 'Риск по участкам (visual ship не повышает)';
|
||||
return `${head}: ${risk.classes.map((cls) => riskClassLine(risk, cls)).join(' · ')}\n<!-- hp:ship-risk classes=${risk.classes.join(',')} -->`;
|
||||
}
|
||||
|
||||
/** Комментарий повышения ship → show: рамки и риск одним комментарием. */
|
||||
export function raiseComment({ violations = [], risk = null, runUrl = '' } = {}) {
|
||||
const parts = ['**Трек повышен: `track:ship` → `track:show`.**'];
|
||||
if (violations.length) parts.push(`Правка выходит за механические рамки ship (PROCESS.md §5): ${violations.join('; ')}.`);
|
||||
if (risk?.raising?.length) {
|
||||
parts.push([
|
||||
'Изменённые участки несут риск, который ship без ревью не пропускает (PROCESS.md §5, #707):',
|
||||
...risk.raising.map((cls) => `- ${riskClassLine(risk, cls)}`),
|
||||
].join('\n'));
|
||||
}
|
||||
parts.push('Слияние без ревью модели для неё закрыто. Этот заход идёт по треку show: лёгкий Validate и ревью модели «корректность и AC».');
|
||||
parts.push(violations.length
|
||||
? 'Понизить трек обратно может только владелец.'
|
||||
: 'Понизить трек может только владелец; подтвердить ship — строкой `Трек: ship — решение владельца` в комментарии и снова `S7-code-review`.');
|
||||
if (runUrl) parts.push(`[Прогон](${runUrl}).`);
|
||||
return `${parts.join('\n\n')}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Решение шага трека на S7 (К3 #707) — чистая функция над метками, диффом и
|
||||
* комментариями. Этап `spec`, отсутствие ветки и инфраструктурный дифф дают
|
||||
* пустой риск и прежнее поведение.
|
||||
*/
|
||||
export function decideTrack({
|
||||
stage = 'code', branch = '', labels = [], files = [], numstat = [], nameStatus = [], diff = '',
|
||||
comments = [], owner = '', runUrl = '',
|
||||
} = {}) {
|
||||
const base = resolveTrack({ labels, files });
|
||||
const code = stage === 'code' && Boolean(branch);
|
||||
const risk = code ? classifyRisk(diff) : emptyRisk();
|
||||
const origin = trackOrigin({ labels, comments, owner, infrastructure: base.infrastructure });
|
||||
let { track } = base;
|
||||
let ship = false; let raise = false; let comment = ''; let violations = [];
|
||||
if (track === 'ship' && code) {
|
||||
violations = shipLimitViolations({ numstat, nameStatus });
|
||||
const riskRaises = risk.raising.length > 0 && !origin.confirmed;
|
||||
if (violations.length || riskRaises) {
|
||||
raise = true; track = 'show';
|
||||
comment = raiseComment({ violations, risk, runUrl });
|
||||
} else {
|
||||
ship = true;
|
||||
}
|
||||
}
|
||||
const confirmed = origin.confirmed && track === origin.track;
|
||||
const reasons = [violations.length && 'рамки ship', raise && risk.raising.length && `риск ${risk.raising.join(', ')}`].filter(Boolean);
|
||||
const basis = raise ? `повышен конвейером с ship (${reasons.join(' и ')}); было: ${origin.basis}` : origin.basis;
|
||||
return {
|
||||
track, mutants: base.mutants, full: base.full, infrastructure: base.infrastructure, ship, raise, comment, violations,
|
||||
risk, confirmed, basis, warning: origin.warning,
|
||||
note: code && !ship ? riskNote({ track, confirmed, risk, labels }) : '',
|
||||
shipRisk: ship ? shipRiskText({ risk, confirmed: origin.confirmed }) : '',
|
||||
};
|
||||
}
|
||||
|
||||
function git(args) {
|
||||
const r = spawnSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
||||
const r = spawnSync('git', args, { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
||||
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${(r.stderr || '').trim()}`);
|
||||
return r.stdout;
|
||||
}
|
||||
|
||||
/** Комментарии из файла `gh issue view --json comments`; пустой или битый файл — недоступны. */
|
||||
export function readComments(path) {
|
||||
if (!path || !existsSync(path)) return null;
|
||||
const text = readFileSync(path, 'utf8').trim();
|
||||
if (!text) return null;
|
||||
try { return normalizeComments(JSON.parse(text)); } catch { return null; }
|
||||
}
|
||||
|
||||
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 labels = value('labels').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
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`);
|
||||
@@ -124,8 +349,50 @@ if (isMainModule(import.meta.url)) {
|
||||
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);
|
||||
if (command === 'stage') {
|
||||
const branch = value('branch');
|
||||
const stage = value('stage') || 'code';
|
||||
const out = value('out');
|
||||
if (!out) throw new Error('--out is required');
|
||||
const diffRange = branch ? `${base || 'origin/dev'}...${head}` : null;
|
||||
const code = stage === 'code' && diffRange;
|
||||
const decision = decideTrack({
|
||||
stage, branch, labels,
|
||||
files: diffRange ? git(['diff', '--name-only', diffRange]).split('\n').filter(Boolean) : [],
|
||||
numstat: code ? parseNumstat(git(['diff', '--numstat', diffRange])) : [],
|
||||
nameStatus: code ? parseNameStatus(git(['diff', '--name-status', diffRange])) : [],
|
||||
diff: code ? git(['-c', 'core.quotePath=false', 'diff', '--unified=0', '-M', '--no-color', '--no-ext-diff', '--no-textconv', diffRange]) : '',
|
||||
comments: readComments(value('comments')),
|
||||
owner: value('owner'),
|
||||
runUrl: value('run-url'),
|
||||
});
|
||||
mkdirSync(out, { recursive: true });
|
||||
if (decision.raise) writeFileSync(join(out, 'raise.md'), decision.comment);
|
||||
const basis = `${decision.basis}${decision.warning ? ` · внимание: ${decision.warning}` : ''}`;
|
||||
const lines = [
|
||||
`track=${decision.track}`, `mutants=${decision.mutants}`, `full=${decision.full}`, `ship=${decision.ship}`,
|
||||
`raise=${decision.raise}`, `basis=${basis}`, `risk=${decision.risk.classes.join(',')}`,
|
||||
];
|
||||
for (const line of lines) console.log(line);
|
||||
if (decision.violations.length) console.log(`violations=${decision.violations.join('; ')}`);
|
||||
if (decision.note) console.log(`risk_note:\n${decision.note}`);
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
const block = (name, text) => {
|
||||
if (!text) return '';
|
||||
const delimiter = `HP_TRACK_${randomUUID()}`;
|
||||
return `${name}<<${delimiter}\n${text}\n${delimiter}\n`;
|
||||
};
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n${block('risk_note', decision.note)}${block('ship_risk', decision.shipRisk)}`);
|
||||
}
|
||||
if (process.env.GITHUB_STEP_SUMMARY) {
|
||||
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `- трек **${decision.track}** · основание: ${basis} · риск по участкам: ${decision.risk.classes.join(', ') || 'нет'} · полный набор: ${decision.full} · слияние без модели: ${decision.ship}${decision.raise ? ' · повышен ship → show' : ''}\n`);
|
||||
}
|
||||
} else if (command === 'limit') {
|
||||
const path = value('files');
|
||||
const files = path && existsSync(path) ? readFileSync(path, 'utf8').split('\n').map((s) => s.trim()).filter(Boolean) : [];
|
||||
const { track, limit, infrastructure } = guardLimit({ labels, files });
|
||||
for (const line of [`track=${track}`, `limit=${limit}`, `infrastructure=${infrastructure}`]) console.log(line);
|
||||
} else if (command === 'resolve') {
|
||||
const files = range ? git(['diff', '--name-only', range]).split('\n').filter(Boolean) : [];
|
||||
const { track, mutants, full } = resolveTrack({ labels, files });
|
||||
emit([`track=${track}`, `mutants=${mutants}`, `full=${full}`]);
|
||||
@@ -137,7 +404,7 @@ if (isMainModule(import.meta.url)) {
|
||||
});
|
||||
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>]');
|
||||
throw new Error('usage: process-track.mjs stage --stage=code|spec --labels=a,b --branch=<b> --base=<ref> --out=<dir> [--head --comments --owner --run-url] | limit --labels=a,b [--files=<file>] | resolve --labels=a,b [--base=<ref> --head=<ref>] | ship-limits --base=<ref> [--head=<ref>]');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`::error::${error.message}`);
|
||||
|
||||
+20
-1
@@ -29,6 +29,8 @@ import { issueTrailers, readCandidateHistory } from './release-membership.mjs';
|
||||
|
||||
export const SHIP_REVIEW_DIR = 'docs/reviews';
|
||||
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}$/;
|
||||
@@ -46,6 +48,21 @@ export function isShipIssue({ labels = [], comments = [] } = {}) {
|
||||
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);
|
||||
@@ -77,7 +94,8 @@ export function shipIssuesInRange({ commits = [], issueData }) {
|
||||
for (const [number, list] of [...byIssue].sort((a, b) => a[0] - b[0])) {
|
||||
const data = issueData(number);
|
||||
if (!data || !isShipIssue(data)) continue;
|
||||
out.push({ number, title: data.title || '', spec: specSection(data.body), commits: list.reverse() });
|
||||
const risk = shipRiskFrom(data.comments);
|
||||
out.push({ number, title: data.title || '', spec: specSection(data.body), ...(risk ? { risk } : {}), commits: list.reverse() });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -97,6 +115,7 @@ export function renderShipBrief({ tag, candidate, base, ship, runUrl = '' }) {
|
||||
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('');
|
||||
|
||||
+184
-14
@@ -7,9 +7,11 @@
|
||||
// свои ошибки («метка встала раньше push'а», «ревьюер читал не тот SHA»).
|
||||
// Скрипт делает эту сборку детерминированно и печатает один markdown-пакет:
|
||||
//
|
||||
// issue · статус и трек · что можно делать в этом статусе · решения владельца ·
|
||||
// материал (ветка, SHA, база, Validate) · предыдущий вердикт · AC → свидетель ·
|
||||
// непроверенное / следующий шаг
|
||||
// issue · статус и трек · трек: основание, лимит, ребейз (#707) · что можно
|
||||
// делать в этом статусе · решения владельца · материал (ветка, SHA, база,
|
||||
// Validate) · следующий шаг по ветке · риск по участкам · обязательные
|
||||
// проверки · changelog и визуальное свидетельство · предыдущий вердикт ·
|
||||
// AC → свидетель · непроверенное
|
||||
//
|
||||
// Источник правды остаётся GitHub и git: пакет ничего не пишет и ничего не
|
||||
// решает. Все чтения инъектируемы — `buildPacket(inputs)` чист и покрыт тестами.
|
||||
@@ -20,7 +22,10 @@ import { spawnSync } from 'node:child_process';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
import { anchorTreeFrom, anchorVerdictFrom, verdictDeclaration } from './review-doc-guard.mjs';
|
||||
import { classify } from './process-gate.mjs';
|
||||
import { hasTrackLabel, trackFromLabels } from './process-track.mjs';
|
||||
import {
|
||||
classifyRisk, cycleLimit, hasTrackLabel, rebaseBeforeReview, riskClassLine, trackFromLabels, trackOrigin,
|
||||
} from './process-track.mjs';
|
||||
import { selectSmokes } from './smoke-select.mjs';
|
||||
|
||||
export const STATUS_LABELS = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready', 'S6-in-progress', 'S7-code-review', 'S8-merged'];
|
||||
|
||||
@@ -128,7 +133,101 @@ export function branchIsInfrastructure(changedFiles = []) {
|
||||
const PRE_CODE_STATUSES = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready'];
|
||||
|
||||
// Трек по меткам — одна функция на конвейер и пакет (#696): process-track.mjs.
|
||||
export { hasTrackLabel, trackFromLabels };
|
||||
// С #707 оттуда же основание трека, лимит циклов, политика ребейза и риск по
|
||||
// изменённым участкам: пакет не держит своей копии правила.
|
||||
export { classifyRisk, cycleLimit, hasTrackLabel, rebaseBeforeReview, trackFromLabels, trackOrigin };
|
||||
|
||||
/** Зеркала junction limits: правка любого требует parity (§8, #548). */
|
||||
export const JUNCTION_MIRRORS = Object.freeze([
|
||||
'src/junction-limits.ts', 'custom_components/houseplan/junction_limits.py', 'test/fixtures/junction-limits-parity.json',
|
||||
]);
|
||||
const CHANGELOGS = ['docs/CHANGELOG.md', 'docs/CHANGELOG.ru.md'];
|
||||
const JUNCTION_PARITY = 'npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs && python tests_backend/junction_parity.py --build-dir=test-build/junction-parity';
|
||||
|
||||
/**
|
||||
* Чистота слияния ветки с `dev` без касания рабочей копии:
|
||||
* `git merge-tree --write-tree` (git 2.38+). `clean: null` — проверить нельзя
|
||||
* (старый git или сбой), и это не «конфликта нет».
|
||||
*/
|
||||
export function readMergeState({ cwd, onto = 'origin/dev', ref, run = spawnSync } = {}) {
|
||||
const r = run('git', ['merge-tree', '--write-tree', '--name-only', '--no-messages', onto, ref], { cwd, encoding: 'utf8' });
|
||||
if (r?.error || (r?.status !== 0 && r?.status !== 1)) return { clean: null, conflicts: [] };
|
||||
if (r.status === 0) return { clean: true, conflicts: [] };
|
||||
const conflicts = [];
|
||||
for (const line of String(r.stdout || '').split('\n').slice(1)) {
|
||||
if (!line.trim()) break;
|
||||
if (!conflicts.includes(line.trim())) conflicts.push(line.trim());
|
||||
}
|
||||
return { clean: false, conflicts };
|
||||
}
|
||||
|
||||
/** Следующий шаг по положению ветки относительно `dev` (#707): без лишнего ребейза. */
|
||||
export function nextStepLines({ branch = null, track = 'ask' } = {}) {
|
||||
if (!branch || !(branch.behind > 0)) return [];
|
||||
const behind = `позади dev на ${branch.behind}`;
|
||||
if (branch.mergeClean === true) {
|
||||
return [track === 'ask'
|
||||
? `${behind}, слияние чистое — конвейер сам приведёт ветку к dev до ревью`
|
||||
: `${behind}, слияние чистое — ребейз не нужен: один раз при слиянии`];
|
||||
}
|
||||
if (branch.mergeClean === false) {
|
||||
const files = branch.conflicts?.length ? branch.conflicts.join(', ') : 'git не назвал файлы';
|
||||
return [`слияние с dev конфликтует: ${files} — ребейз до S7 (\`node scripts/rebase-on-dev.mjs\`)`];
|
||||
}
|
||||
return [`${behind}: чистота слияния не проверена (\`git merge-tree --write-tree\` недоступен, нужен git 2.38+)`];
|
||||
}
|
||||
|
||||
/** Следствие риска по треку (#707): что сделает конвейер или ревьюер. */
|
||||
export function riskConsequence({ track, confirmed = false, risk } = {}) {
|
||||
if (!risk?.raising?.length) return 'справочно: visual трек не повышает (§5)';
|
||||
if (track === 'ship') return confirmed ? 'не повысит; риск прочтёт пакетное ревью' : 'конвейер повысит до show при S7';
|
||||
if (track === 'show') return 'ревьюер спросит, где поведение зафиксировано; нет ссылки — повысить до ask до S7 (§5)';
|
||||
return 'справочно';
|
||||
}
|
||||
|
||||
/**
|
||||
* Обязательные проверки «команда · основание» (#707, §8). `ci:golden` — только
|
||||
* при визуальном риске в пути отрисовки: CSS интерфейса, комментарии и тесты
|
||||
* кадров плана не двигают.
|
||||
*/
|
||||
export function requiredChecks({ risk = null, changedFiles = [], smokes = null, labels = [] } = {}) {
|
||||
const out = [{ command: '`npm run gate:small`', reason: 'всегда (§8)' }];
|
||||
for (const entry of smokes?.direct || []) {
|
||||
out.push({ command: `\`node demo/${entry.smoke}\``, reason: `smoke-select: прямое совпадение (${entry.symbols.slice(0, 4).join(', ')})` });
|
||||
}
|
||||
for (const entry of smokes?.registered || []) {
|
||||
out.push({ command: `\`node demo/${entry.smoke}\``, reason: `smoke-select: зарегистрированная связь (${entry.symbols.slice(0, 4).join(', ')})` });
|
||||
}
|
||||
if (smokes?.visualMinimum?.length) {
|
||||
out.push({ command: '`npm run gate:small -- --smokes`', reason: `smoke-select: визуальный минимум — связь диффа со смоками не доказана (#690): ${smokes.visualMinimum.map((s) => s.replace(/\.mjs$/, '')).join(', ')}` });
|
||||
}
|
||||
if (risk?.classes?.includes('geometry')) {
|
||||
out.push({ command: '`npm run invariants -- --config <экспорт>`', reason: 'риск geometry (§8, #254)' });
|
||||
}
|
||||
const python = changedFiles.filter((file) => /^custom_components\/.+\.py$/.test(file));
|
||||
if (python.length) {
|
||||
out.push({ command: '`python -m pytest tests_backend -q`', reason: `изменён Python: ${python.slice(0, 3).join(', ')}${python.length > 3 ? ` и ещё ${python.length - 3}` : ''}` });
|
||||
}
|
||||
const mirrors = changedFiles.filter((file) => JUNCTION_MIRRORS.includes(file));
|
||||
if (mirrors.length) out.push({ command: `\`${JUNCTION_PARITY}\``, reason: `junction parity: изменено зеркало ${mirrors.join(', ')} (§8)` });
|
||||
if (risk?.visual?.render) {
|
||||
out.push({ command: 'метка `ci:golden`', reason: labels.includes('ci:golden')
|
||||
? 'стоит: golden на ветке и приёмка сдвинутых кадров в задаче (§5.1)'
|
||||
: 'рекомендовано, если сдвиг кадров намерен: визуальный риск в пути отрисовки (§5.1, §8)' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Трейлеры `User-Visible` коммитов ветки и changelog в её диффе (§3 п.10, §7.1). */
|
||||
export function changelogState({ commits = [], changedFiles = [], risk = null } = {}) {
|
||||
const yes = commits.filter((c) => /^User-Visible:\s*yes\s*$/im.test(String(c.message ?? ''))).length;
|
||||
const no = commits.filter((c) => /^User-Visible:\s*no\s*$/im.test(String(c.message ?? ''))).length;
|
||||
return {
|
||||
yes, no,
|
||||
missing: yes ? CHANGELOGS.filter((file) => !changedFiles.includes(file)) : [],
|
||||
visualEvidence: Boolean(risk?.visual?.render),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Признаки продуктового S-flow (#632). Инфраструктурная задача входит в поток
|
||||
@@ -168,13 +267,26 @@ export function buildPacket(inputs) {
|
||||
const infrastructure = branch?.infrastructure === true && productFlow.length === 0;
|
||||
const infrastructureHint = branch == null && status == null && labels.includes('infra') && productFlow.length === 0;
|
||||
// §5.1 (r1 #695): инфраструктурная задача без трековой метки идёт как
|
||||
// `show`; явная метка владельца главнее. Маршрут при этом остаётся
|
||||
// инфраструктурным — вход сразу на S7, без S1–S5.
|
||||
const infraTrack = hasTrackLabel(labels) ? trackFromLabels(labels) : 'show';
|
||||
// `show`; явная метка главнее. Маршрут при этом остаётся инфраструктурным —
|
||||
// вход сразу на S7, без S1–S5. Трек, основание и лимит — те же функции, что
|
||||
// у конвейера (#707).
|
||||
const origin = trackOrigin({ labels, comments, owner, infrastructure: infrastructure || infrastructureHint });
|
||||
const track = infrastructure
|
||||
? `инфраструктурный · ${infraTrack}`
|
||||
: infrastructureHint ? `инфраструктурный · ${infraTrack} (предварительно; подтвердить путями/diff)`
|
||||
: trackFromLabels(labels);
|
||||
? `инфраструктурный · ${origin.track}`
|
||||
: infrastructureHint ? `инфраструктурный · ${origin.track} (предварительно; подтвердить путями/diff)`
|
||||
: origin.track;
|
||||
const mergeClean = branch ? (branch.behind > 0 ? (branch.mergeClean ?? null) : true) : null;
|
||||
const trackDetail = {
|
||||
track: origin.track, basis: origin.basis, warning: origin.warning, confirmed: origin.confirmed,
|
||||
limit: cycleLimit(origin.track),
|
||||
// null — политика зависит от чистоты слияния, а её здесь не проверить.
|
||||
rebaseBeforeReview: origin.track !== 'ask' && mergeClean === null ? null : rebaseBeforeReview(origin.track, mergeClean),
|
||||
};
|
||||
const risk = !branch ? { computed: false, reason: 'не посчитан: ветки нет' }
|
||||
: typeof branch.diff !== 'string' ? { computed: false, reason: 'не посчитан: дифф ветки не прочитан' }
|
||||
: { computed: true, ...classifyRisk(branch.diff) };
|
||||
if (risk.computed && risk.classes.length) risk.consequence = riskConsequence({ track: origin.track, confirmed: origin.confirmed, risk });
|
||||
const changedFiles = branch?.changedFiles || [];
|
||||
const stage = status === 'S4-spec-review' || status === 'S3-spec' || status === 'S5-ready' ? 'spec' : 'code';
|
||||
const verdict = lastVerdict(comments, reviewDocs, stage);
|
||||
// ТЗ живёт в теле issue (#517); архивный файл — источник только у задач до
|
||||
@@ -188,13 +300,17 @@ export function buildPacket(inputs) {
|
||||
const unverified = acs.filter((a) => a.evidence.startsWith('без записи'));
|
||||
const packet = {
|
||||
issue: { number: issue.number, title: issue.title, state: issue.state, url: issue.url },
|
||||
status, track, labels, productFlow, rights: rightsFor(status, labels, { infrastructure, infrastructureHint }),
|
||||
status, track, trackDetail, labels, productFlow, rights: rightsFor(status, labels, { infrastructure, infrastructureHint }),
|
||||
decisions: ownerDecisions(comments, owner),
|
||||
material: branch ? {
|
||||
branch: branch.name, tip: branch.tip, base: branch.base, ahead: branch.ahead, behind: branch.behind,
|
||||
treeMatchesVerdict: verdict.doc?.tree ? branch.treeWithoutReviews === verdict.doc.tree : null,
|
||||
validate: validate || { status: 'неизвестно' },
|
||||
} : null,
|
||||
nextStep: nextStepLines({ branch: branch ? { ...branch, mergeClean } : null, track: origin.track }),
|
||||
risk,
|
||||
checks: requiredChecks({ risk: risk.computed ? risk : null, changedFiles, smokes: branch?.smokes ?? null, labels }),
|
||||
changelog: branch ? changelogState({ commits: branch.commits || [], changedFiles, risk: risk.computed ? risk : null }) : null,
|
||||
verdict,
|
||||
acceptance: acs,
|
||||
unverified: unverified.map((a) => a.id),
|
||||
@@ -209,6 +325,15 @@ export function renderPacket(p) {
|
||||
L.push(`Статус: **${p.status || 'без S-метки'}** · трек: ${p.track} · метки: ${p.labels.join(', ') || '—'} · issue ${p.issue.state}`);
|
||||
if (p.productFlow?.length) L.push(`Продуктовый поток: ${p.productFlow.join(', ')} — дифф без класса A трек не меняет (#632)`);
|
||||
L.push('');
|
||||
if (p.trackDetail) {
|
||||
const t = p.trackDetail;
|
||||
const rebase = t.rebaseBeforeReview === true ? 'да' : t.rebaseBeforeReview === false ? 'нет' : 'только при конфликте с dev (чистота слияния не проверена)';
|
||||
L.push('## Трек');
|
||||
L.push(`- ${t.track} · основание: ${t.basis}`);
|
||||
if (t.warning) L.push(`- внимание: ${t.warning}`);
|
||||
L.push(`- лимит циклов код-ревью: ${t.limit} · ребейз до ревью: ${rebase}`);
|
||||
L.push('');
|
||||
}
|
||||
L.push('## Права и следующий шаг');
|
||||
for (const r of p.rights) L.push(`- ${r}`);
|
||||
L.push('');
|
||||
@@ -220,12 +345,39 @@ export function renderPacket(p) {
|
||||
if (!p.material) L.push('- ветки issue/NN-* на origin нет — материал не запушен');
|
||||
else {
|
||||
const m = p.material;
|
||||
L.push(`- ветка \`${m.branch}\`, вершина \`${m.tip.slice(0, 12)}\`, база dev \`${m.base.slice(0, 12)}\`: впереди ${m.ahead}, позади ${m.behind}${m.behind ? ' — перед S7 ребейз (rebase-on-dev.mjs при конфликте в бандле)' : ''}`);
|
||||
L.push(`- ветка \`${m.branch}\`, вершина \`${m.tip.slice(0, 12)}\`, база dev \`${m.base.slice(0, 12)}\`: впереди ${m.ahead}, позади ${m.behind}`);
|
||||
L.push(`- Validate на вершине: ${m.validate.status}${m.validate.url ? ` (${m.validate.url})` : ''}`);
|
||||
if (m.treeMatchesVerdict === true) L.push('- дерево вне docs/reviews совпадает с материалом последнего вердикта — повторный S7 применит его без модели (#499)');
|
||||
if (m.treeMatchesVerdict === false) L.push('- дерево изменилось с последнего вердикта — будет полный разбор');
|
||||
}
|
||||
L.push('');
|
||||
if (p.nextStep?.length) {
|
||||
L.push('## Следующий шаг');
|
||||
for (const line of p.nextStep) L.push(`- ${line}`);
|
||||
L.push('');
|
||||
}
|
||||
if (p.risk && (!p.risk.computed ? p.risk.reason === 'не посчитан: ветки нет' : p.risk.classes.length)) {
|
||||
L.push('## Риск по участкам');
|
||||
if (!p.risk.computed) L.push(`- ${p.risk.reason}`);
|
||||
else {
|
||||
for (const cls of p.risk.classes) L.push(`- ${riskClassLine(p.risk, cls)}`);
|
||||
L.push(`- следствие: ${p.risk.consequence}`);
|
||||
}
|
||||
L.push('');
|
||||
}
|
||||
if (p.checks?.length) {
|
||||
L.push('## Обязательные проверки');
|
||||
for (const check of p.checks) L.push(`- ${check.command} · ${check.reason}`);
|
||||
L.push('');
|
||||
}
|
||||
const c = p.changelog;
|
||||
if (c && (c.yes || c.no || c.visualEvidence)) {
|
||||
L.push('## Changelog и визуальное свидетельство');
|
||||
if (c.yes || c.no) L.push(`- коммитов ветки: \`User-Visible: yes\` — ${c.yes}, \`User-Visible: no\` — ${c.no}`);
|
||||
for (const file of c.missing) L.push(`- не хватает ${file}: \`User-Visible: yes\` требует правок в обоих changelog (§3 п.10)`);
|
||||
if (c.visualEvidence) L.push('- визуальный риск в пути отрисовки: дефект растра или резкости требует свидетеля, красного на старом коде, и подтверждения владельца в GPU-браузере (§7.1)');
|
||||
L.push('');
|
||||
}
|
||||
L.push('## Предыдущий вердикт');
|
||||
if (p.verdict.comment) L.push(`- комментарий: ${p.verdict.comment.line}${p.verdict.comment.url ? ` (${p.verdict.comment.url})` : ''}`);
|
||||
if (p.verdict.doc) L.push(`- документ: \`${p.verdict.doc.name}\`, дерево \`${p.verdict.doc.tree?.slice(0, 12) || '—'}\`, запись конвейера: ${p.verdict.doc.recorded ? `${p.verdict.doc.recorded.verdict} · High ${p.verdict.doc.recorded.high}` : 'нет (документ до #499)'}`);
|
||||
@@ -265,6 +417,21 @@ export function collectInputs({ number, repo = 'Matysh/houseplan-card', cwd = pr
|
||||
const behind = Number(sh('git', ['rev-list', '--count', `${ref}..origin/dev`], { cwd }));
|
||||
const changedFiles = sh('git', ['diff', '--name-only', `${base}..${ref}`], { cwd }).split('\n').filter(Boolean);
|
||||
const infrastructure = branchIsInfrastructure(changedFiles);
|
||||
// #707: риск по изменённым участкам, смоки и трейлеры — по тому же диффу от
|
||||
// merge-base, что судит конвейер на S7.
|
||||
const diff = sh('git', ['-c', 'core.quotePath=false', 'diff', '--unified=0', '-M', '--no-color', '--no-ext-diff', '--no-textconv', `${base}..${ref}`], { cwd });
|
||||
const commits = sh('git', ['log', '--format=%B%x1e', `${base}..${ref}`], { cwd })
|
||||
.split('\x1e').map((message) => message.trim()).filter(Boolean).map((message) => ({ message }));
|
||||
const merge = behind > 0 ? readMergeState({ cwd, onto: 'origin/dev', ref }) : { clean: true, conflicts: [] };
|
||||
let smokes = null;
|
||||
try {
|
||||
const selection = selectSmokes(diff);
|
||||
smokes = {
|
||||
direct: selection.direct.filter((entry) => entry.strong).map(({ smoke, symbols }) => ({ smoke, symbols })),
|
||||
registered: selection.registered.map(({ smoke, symbols }) => ({ smoke, symbols })),
|
||||
visualMinimum: selection.visualMinimum,
|
||||
};
|
||||
} catch { smokes = null; }
|
||||
// Дерево без docs/reviews — для сравнения с якорем вердикта: git сам его не даёт,
|
||||
// поэтому сравнение делается diff'ом при известном якоре (см. ниже).
|
||||
const names = sh('git', ['ls-tree', '--name-only', `${ref}:docs/reviews`], { cwd }).split('\n').filter((n) => new RegExp(`-${number}-r\\d+\\.md$`).test(n));
|
||||
@@ -277,7 +444,10 @@ export function collectInputs({ number, repo = 'Matysh/houseplan-card', cwd = pr
|
||||
const same = spawnSync('git', ['diff', '--quiet', anchorTree, tip, '--', '.', ':!docs/reviews', ':!legacy/reviews'], { cwd });
|
||||
treeWithoutReviews = same.status === 0 ? anchorTree : `differs-from-${anchorTree}`;
|
||||
}
|
||||
branch = { name, tip, base, ahead, behind, treeWithoutReviews, infrastructure };
|
||||
branch = {
|
||||
name, tip, base, ahead, behind, treeWithoutReviews, infrastructure,
|
||||
changedFiles, diff, commits, smokes, mergeClean: merge.clean, conflicts: merge.conflicts,
|
||||
};
|
||||
}
|
||||
let validate = null;
|
||||
if (branch) {
|
||||
|
||||
Reference in New Issue
Block a user