mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 15:09:30 +00:00
fix(wait-verdict): tell a red Validate from a rebase conflict before review (#810)
Both pre-review comments of _process.yml start with «**Ревью не запускалось:**»: the rebase conflict and the red or missing Validate on the material. The waiter matched the shared prefix alone and printed «конфликт разрешает автор» for both, sending the author to rebase a branch that has no conflict instead of reading the Validate run. PIPELINE_EVENTS now holds three signs instead of one: validate-red (its own line: Validate on the material is red or missing, read the run, a code change is not required), conflict (the rebase conflict, the old line) and not-run (the family with an unrecognised continuation: read the comment). All of them exit 3, as before. The two cause regexes used to live in process-metrics.mjs as a copy of the templates; process-metrics now takes them from PIPELINE_EVENTS by kind and re-exports them under the same names, so the waiter and the K3 return reasons share one source. The process-metrics contract test now finds the common prefix under kind not-run, which is what the family entry is called now. Tests build the bodies by running the two _process.yml steps as the runner does (runStep, gh and git stubbed), for the conflict and for both Validate kinds. The three new #810 cases are red on the original scripts. The new mutant wait-verdict-not-run-validate-unrecognised was checked by hand: its guard goes red. Issue: #810 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:
@@ -12475,6 +12475,18 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: ' const sign = null; // mutant: merge-candidate outcomes are not recognised',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'wait-verdict-not-run-validate-unrecognised',
|
||||
guard: 'node --test --test-name-pattern="#810" test/wait-verdict.test.mjs',
|
||||
because: '#810: «Ревью не запускалось» from the red or missing Validate step of _process.yml is its own '
|
||||
+ 'event, not a rebase conflict; the sign must match the body the step writes for both kinds '
|
||||
+ '(«Validate» and «Validate с мутантами»), otherwise the author is sent to the wrong action',
|
||||
patches: [{
|
||||
file: 'scripts/wait-verdict.mjs',
|
||||
find: "Validate(?: с мутантами)? на материале /m, kind: 'validate-red'",
|
||||
replace: "Validate на материале /m, kind: 'validate-red'",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'release-gate-counts-cancelled-runs',
|
||||
guard: 'node --test test/release-gate.test.mjs',
|
||||
|
||||
@@ -213,15 +213,16 @@ export const VALIDATE_WORKFLOW = 'Проверка (CI)';
|
||||
export const TOKENS_NO_DATA = 'Токены: нет данных (ни один документ ревью не несёт расход модели)';
|
||||
|
||||
/**
|
||||
* Причины `validate-red` и `conflict` (К3). У конвейера нет для них отдельной
|
||||
* константы: оба комментария `_process.yml` («Validate красный — вернуть
|
||||
* автору без ревью» и «Конфликт с dev — вернуть автору без ревью») идут под
|
||||
* одним префиксом `PIPELINE_EVENTS` с `kind: 'conflict'`. Причину даёт
|
||||
* продолжение первой строки. Это копия текста шаблонов, а не импорт: от
|
||||
* расхождения её держит контрактный тест на самих шаблонах (AC3 #728).
|
||||
* Причины `validate-red` и `conflict` (К3). Оба комментария `_process.yml`
|
||||
* («Validate красный — вернуть автору без ревью» и «Конфликт с dev — вернуть
|
||||
* автору без ревью») начинаются с «**Ревью не запускалось:**», причину даёт
|
||||
* продолжение первой строки. Признаки — из `PIPELINE_EVENTS` по `kind`: один
|
||||
* источник с ожиданием вердикта, которое по ним же называет автору причину
|
||||
* (#810). От расхождения с шаблонами держит контрактный тест на самих шаблонах
|
||||
* (AC3 #728).
|
||||
*/
|
||||
export const NOT_RUN_VALIDATE_RE = /^\*\*Ревью не запускалось:\*\* Validate(?: с мутантами)? на материале /m;
|
||||
export const NOT_RUN_CONFLICT_RE = /^\*\*Ревью не запускалось:\*\* ветка \S+ не ребейзится на /m;
|
||||
export const NOT_RUN_VALIDATE_RE = pipelineEvent('validate-red');
|
||||
export const NOT_RUN_CONFLICT_RE = pipelineEvent('conflict');
|
||||
/** Маршрут вердикта show (#726): машинная строка комментария конвейера. */
|
||||
export const ROUTE_RE = /<!--\s*hp:route\s+(reclassify|owner-question)\b[^>]*-->/;
|
||||
|
||||
@@ -232,7 +233,7 @@ function pipelineEvent(kind) {
|
||||
return event.re;
|
||||
}
|
||||
/** Общий префикс «Ревью не запускалось»: семейство, не причина. */
|
||||
const NOT_RUN_RE = pipelineEvent('conflict');
|
||||
const NOT_RUN_RE = pipelineEvent('not-run');
|
||||
/** Неудачное слияние после ревью: «Слияние отменено», «Код-ревью зелёное — вердикт выше в силе». */
|
||||
const MERGE_RES = [pipelineEvent('stale'), pipelineEvent('merge-conflict')];
|
||||
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
// и рассуждением «ничего не изменилось» — десятки пустых ходов на одно ревью.
|
||||
// Этот скрипт делает опрос сам и ГОВОРИТ только при смене состояния: одинаковое
|
||||
// состояние не будит никого. Что доставляется: смена статусной метки (вердикт),
|
||||
// отказ конвейера (комментарий «Ревью не запускалось» / «Автоматическое ревью не
|
||||
// отработало»), исход слияния кандидата (#768: по каталогу `OUTCOME_SIGNS`
|
||||
// отказ конвейера (комментарий «Ревью не запускалось» — конфликт ребейза или,
|
||||
// отдельно, красный/не найденный Validate на материале, #810; «Автоматическое
|
||||
// ревью не отработало»), исход слияния кандидата (#768: по каталогу `OUTCOME_SIGNS`
|
||||
// `merge-candidate.mjs` — отменено, конфликт, красный или не дождавшийся
|
||||
// Validate кандидат, ушедший dev, отказ push, сбой шага), маршрут вердикта show
|
||||
// (#726: трек повышен до ask / вопрос владельцу), `blocked`, `review-4`, а при
|
||||
@@ -42,9 +43,19 @@ const STATUS = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready'
|
||||
* Исходы `merge-candidate.mjs` узнаются раньше, по его каталогу
|
||||
* (`OUTCOME_EVENTS` ниже); `stale` и `merge-conflict` здесь — префиксы для тел
|
||||
* до #752 и признаки, которые по `kind` импортирует `process-metrics.mjs`.
|
||||
*
|
||||
* #810: «**Ревью не запускалось:**» — общий префикс двух комментариев
|
||||
* `_process.yml` до ревью: «Конфликт с dev — вернуть автору без ревью» и
|
||||
* «Validate красный — вернуть автору без ревью» (красный или не найденный
|
||||
* прогон на материале — не конфликт). Причину даёт продолжение первой строки;
|
||||
* эти же признаки `validate-red`, `conflict` и `not-run` (семейство без
|
||||
* узнанной причины) `process-metrics.mjs` берёт по `kind` (К3 #728) — один
|
||||
* источник, не копия.
|
||||
*/
|
||||
export const PIPELINE_EVENTS = [
|
||||
{ re: /^\*\*Ревью не запускалось:\*\*/m, kind: 'conflict', text: 'конвейер: ветка не ребейзится на dev — конфликт разрешает автор' },
|
||||
{ re: /^\*\*Ревью не запускалось:\*\* Validate(?: с мутантами)? на материале /m, kind: 'validate-red', text: 'конвейер: Validate на материале красный/не найден — разобрать прогон, править код не обязательно' },
|
||||
{ re: /^\*\*Ревью не запускалось:\*\* ветка \S+ не ребейзится на /m, kind: 'conflict', text: 'конвейер: ветка не ребейзится на dev — конфликт разрешает автор' },
|
||||
{ re: /^\*\*Ревью не запускалось:\*\*/m, kind: 'not-run', text: 'конвейер: ревью не запускалось, причина не распознана — читать комментарий' },
|
||||
{ re: /^\*\*Слияние отменено/m, kind: 'stale', text: 'конвейер: слияние отменено — вершина ветки ушла от проверенного SHA (#312)' },
|
||||
{ re: /^\*\*Код-ревью зелёное — вердикт выше в силе/m, kind: 'merge-conflict', text: 'конвейер: вердикт зелёный, слияние конфликтует — ребейз (rebase-on-dev.mjs) и снова S7' },
|
||||
{ re: /^Автоматическое ревью не отработало/m, kind: 'failure', text: 'конвейер: прогон ревью упал — метка не менялась, смотреть логи, сообщить владельцу' },
|
||||
|
||||
@@ -295,7 +295,7 @@ test('#728 контракт: шаблоны «Ревью не запускало
|
||||
assert.deepEqual(reasons.map((r) => r.join(',')).sort(), ['conflict', 'validate-red'],
|
||||
'один шаблон — conflict, другой — validate-red при обоих $kind');
|
||||
for (const variants of templates) {
|
||||
for (const text of variants) assert.ok(PIPELINE_EVENTS.find((e) => e.kind === 'conflict').re.test(text), 'общий префикс — под константой PIPELINE_EVENTS');
|
||||
for (const text of variants) assert.ok(PIPELINE_EVENTS.find((e) => e.kind === 'not-run').re.test(text), 'общий префикс — под константой PIPELINE_EVENTS');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+125
-2
@@ -1,14 +1,84 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { decide, reviewRequestFromEvents, stateOf, waitForVerdict } from '../scripts/wait-verdict.mjs';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { PIPELINE_EVENTS, decide, reviewRequestFromEvents, stateOf, waitForVerdict } from '../scripts/wait-verdict.mjs';
|
||||
import { NOT_RUN_CONFLICT_RE, NOT_RUN_VALIDATE_RE, returnSignal } from '../scripts/process-metrics.mjs';
|
||||
import { reviewRoute, routeComment } from '../scripts/process-track.mjs';
|
||||
import { OUTCOME_SIGNS, PUSH_REFUSAL, commentFor, describePushRefusal } from '../scripts/merge-candidate.mjs';
|
||||
import { findStep, runStep, workflowSteps } from './helpers/workflow-step.mjs';
|
||||
|
||||
// #496: ожидание детерминировано — одинаковое состояние молчит, смена метки и
|
||||
// события конвейера доставляются один раз, ничего не пишется.
|
||||
|
||||
const snap = (labels, comments = [], validate) => ({ labels, comments, validate });
|
||||
|
||||
const WORKFLOW = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8');
|
||||
/** #810: шаги `_process.yml`, которые пишут «**Ревью не запускалось:**». */
|
||||
const NOT_RUN_STEPS = Object.freeze({
|
||||
conflict: 'Конфликт с dev — вернуть автору без ревью',
|
||||
validate: 'Validate красный — вернуть автору без ревью',
|
||||
});
|
||||
|
||||
/**
|
||||
* #810: комментарий шага `name` — тело шага исполняется как у раннера
|
||||
* (`runStep`), а не копируется строкой. `gh` подменён и сохраняет
|
||||
* `--body-file` комментария; `git rev-parse --short` отвечает началом SHA.
|
||||
* Выражения `${{ … }}` раннер подставляет до запуска — здесь тоже; файлы
|
||||
* `/tmp/*.md` шага уходят во временный каталог теста.
|
||||
*/
|
||||
function notRunComment(name, env) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'hp-wait-verdict-'));
|
||||
try {
|
||||
const bin = join(root, 'bin');
|
||||
mkdirSync(bin);
|
||||
writeFileSync(join(bin, 'gh'), [
|
||||
'#!/usr/bin/env bash',
|
||||
'if [ "$1 $2" = "issue comment" ]; then',
|
||||
' while [ $# -gt 0 ]; do if [ "$1" = --body-file ]; then cp "$2" "$CAPTURE"; fi; shift; done',
|
||||
'fi',
|
||||
'',
|
||||
].join('\n'), { mode: 0o755 });
|
||||
writeFileSync(join(bin, 'git'), [
|
||||
'#!/usr/bin/env bash',
|
||||
'if [ "$1 $2" = "rev-parse --short" ]; then echo "${3:0:7}"; exit 0; fi',
|
||||
'echo "git $*: шаг не должен звать" >&2; exit 1',
|
||||
'',
|
||||
].join('\n'), { mode: 0o755 });
|
||||
const step = findStep(WORKFLOW, { name }, '_process.yml');
|
||||
const script = step.run
|
||||
.replace(/\$\{\{\s*github\.server_url\s*\}\}/g, 'https://github.com')
|
||||
.replace(/\$\{\{\s*github\.repository\s*\}\}/g, 'o/r')
|
||||
.replace(/\$\{\{\s*github\.run_id\s*\}\}/g, '1')
|
||||
.replaceAll('/tmp/', `${root}/`);
|
||||
assert.ok(!script.includes('${{'), `${name}: выражение раннера осталось без подстановки`);
|
||||
const capture = join(root, 'comment.md');
|
||||
const r = runStep(step, script, { env: { PATH: `${bin}:${process.env.PATH}`, CAPTURE: capture, NUM: '7', ...env } });
|
||||
assert.equal(r.status, 0, `${name}: ${r.stderr}`);
|
||||
return readFileSync(capture, 'utf8');
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
let notRunCache = null;
|
||||
/** Конфликт ребейза и оба вида Validate-шага: с мутантами (красный) и лёгкий (не найден). */
|
||||
function notRunBodies() {
|
||||
notRunCache ??= {
|
||||
conflict: notRunComment(NOT_RUN_STEPS.conflict, { BRANCH: 'issue/7-x', CONFLICTS: 'scripts/a.mjs' }),
|
||||
validate: [
|
||||
notRunComment(NOT_RUN_STEPS.validate, {
|
||||
BRANCH: 'issue/7-x', SHA: 'a'.repeat(40), MUTANTS: 'true', RESULT: 'failed', NOTE: 'прогон завершился с failure', URL: 'https://github.com/o/r/actions/runs/2',
|
||||
}),
|
||||
notRunComment(NOT_RUN_STEPS.validate, {
|
||||
BRANCH: 'issue/7-x', SHA: 'b'.repeat(40), MUTANTS: 'false', RESULT: 'missing', NOTE: 'прогона на материале нет', URL: '',
|
||||
}),
|
||||
],
|
||||
};
|
||||
return notRunCache;
|
||||
}
|
||||
|
||||
test('одинаковое состояние не порождает ни строки; смена метки — завершение с 0 (#496)', async () => {
|
||||
const states = [snap(['S7-code-review', 'P2']), snap(['S7-code-review', 'P2']), snap(['S7-code-review', 'P2']), snap(['S8-merged', 'P2'])];
|
||||
let i = 0; const lines = []; let slept = 0;
|
||||
@@ -24,7 +94,7 @@ test('одинаковое состояние не порождает ни ст
|
||||
});
|
||||
|
||||
test('событие конвейера доставляется один раз и требует действия (код 3) (#496)', async () => {
|
||||
const conflict = { id: 'c1', createdAt: '1', body: '**Ревью не запускалось:** ветка `issue/1-x` не ребейзится…' };
|
||||
const conflict = { id: 'c1', createdAt: '1', body: notRunBodies().conflict };
|
||||
const states = [snap(['S7-code-review']), snap(['S6-in-progress'], [conflict])];
|
||||
let i = 0; const lines = [];
|
||||
const code = await waitForVerdict({ readSnapshot: async () => states[Math.min(i++, 1)], intervalMs: 1, maxTicks: 5, sleep: async () => {}, log: (l) => lines.push(l) });
|
||||
@@ -273,3 +343,56 @@ test('#768 сменившаяся метка: S6 — код 0 и причина
|
||||
assert.equal(done.code, 0);
|
||||
assert.deepEqual(done.lines.map((line) => line.replace(/^\[[^\]]*\] /, '')), ['метка: S7-code-review → S8-merged']);
|
||||
});
|
||||
|
||||
// #810: «Ревью не запускалось:» пишут два шага _process.yml — конфликт ребейза
|
||||
// и красный/не найденный Validate на материале. Это разные события: второе —
|
||||
// не git-конфликт, автор разбирает прогон, а не ребейзит.
|
||||
const NOT_RUN_VALIDATE_LINE = 'конвейер: Validate на материале красный/не найден — разобрать прогон, править код не обязательно';
|
||||
|
||||
test('#810 «Ревью не запускалось»: Validate на материале — своё событие, конфликт ребейза — своё (тела из шагов _process.yml)', () => {
|
||||
// Писателей «Ревью не запускалось:» ровно два: третий шаг без своего события краснеет здесь.
|
||||
const writers = workflowSteps(WORKFLOW, '_process.yml').filter((step) => step.run?.includes('**Ревью не запускалось:**')).map((step) => step.name);
|
||||
assert.deepEqual(writers.sort(), Object.values(NOT_RUN_STEPS).sort());
|
||||
const { conflict, validate } = notRunBodies();
|
||||
assert.ok(validate[0].includes('Validate с мутантами на материале') && validate[0].includes('**failed**'), 'шаг с мутантами, прогон красный');
|
||||
assert.ok(!validate[1].includes('с мутантами') && validate[1].includes('**missing**'), 'лёгкий шаг, прогон не найден');
|
||||
const before = stateOf({ ...snap(['S7-code-review']), reviewRequest: REQUEST });
|
||||
const after = (labels, body) => stateOf({ ...snap(labels, [{ id: 'n', createdAt: '2026-10-01T10:05:00Z', body }]), reviewRequest: REQUEST });
|
||||
for (const [index, body] of validate.entries()) {
|
||||
const state = after(['S7-code-review'], body);
|
||||
assert.equal(state.lastEvent?.kind, 'validate-red', `Validate #${index}: вид события`);
|
||||
const held = decide(before, state);
|
||||
assert.equal(held.code, 3, `Validate #${index}: действие автора — код 3`);
|
||||
assert.deepEqual(held.lines, [NOT_RUN_VALIDATE_LINE], `Validate #${index}: не «конфликт разрешает автор»`);
|
||||
// Шаг сразу переводит метку в S6: код 0 смены метки, причина в строках та же.
|
||||
const moved = decide(before, after(['S6-in-progress'], body));
|
||||
assert.equal(moved.code, 0);
|
||||
assert.deepEqual(moved.lines, ['метка: S7-code-review → S6-in-progress', NOT_RUN_VALIDATE_LINE]);
|
||||
}
|
||||
const state = after(['S7-code-review'], conflict);
|
||||
assert.equal(state.lastEvent?.kind, 'conflict');
|
||||
const held = decide(before, state);
|
||||
assert.equal(held.code, 3);
|
||||
assert.deepEqual(held.lines, ['конвейер: ветка не ребейзится на dev — конфликт разрешает автор']);
|
||||
});
|
||||
|
||||
test('#810 один источник с process-metrics: те же признаки PIPELINE_EVENTS, та же причина', () => {
|
||||
const byKind = (kind) => PIPELINE_EVENTS.find((event) => event.kind === kind)?.re;
|
||||
assert.equal(NOT_RUN_VALIDATE_RE, byKind('validate-red'), 'NOT_RUN_VALIDATE_RE — признак PIPELINE_EVENTS, не копия');
|
||||
assert.equal(NOT_RUN_CONFLICT_RE, byKind('conflict'), 'NOT_RUN_CONFLICT_RE — признак PIPELINE_EVENTS, не копия');
|
||||
const { conflict, validate } = notRunBodies();
|
||||
for (const body of [conflict, ...validate]) {
|
||||
const kind = stateOf(snap(['S7-code-review'], [{ id: 'n', createdAt: '1', body }])).lastEvent?.kind;
|
||||
assert.equal(kind, returnSignal(body, { stage: 'code', number: 7 }), `ожидание и метрики называют одну причину: ${body.split('\n')[0]}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('#810 «Ревью не запускалось» с неизвестным продолжением — не конфликт: читать комментарий, код 3', () => {
|
||||
const body = '**Ревью не запускалось:** причина, которой в шаблонах ещё нет.';
|
||||
const state = stateOf(snap(['S7-code-review'], [{ id: 'x', createdAt: '1', body }]));
|
||||
assert.equal(state.lastEvent?.kind, 'not-run');
|
||||
const d = decide(stateOf(snap(['S7-code-review'])), state);
|
||||
assert.equal(d.code, 3);
|
||||
assert.deepEqual(d.lines, ['конвейер: ревью не запускалось, причина не распознана — читать комментарий']);
|
||||
assert.equal(returnSignal(body, { stage: 'code', number: 7 }), 'unknown', 'метрики: семейство узнано, причина — unknown');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user