mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 23:19:14 +00:00
fix(wait-verdict): recognise merge outcomes through OUTCOME_SIGNS (#768)
The waiter knew two of the eleven merge-candidate outcomes, and those through copied prefixes. A red or stuck candidate, a moving dev, a push refusal (#705) or a failed merge step went unrecognised. In the window where the comment is already posted but the label still reads S7, the author waited until the timeout. Once the label moved to S6, only «S7 → S6» was printed and the reason was lost. Comments are now classified by merge-candidate's own catalog (outcomeOf) first, then by the pipeline's own comments as before. The outcomes are mapped by kind, not collapsed into one error: - terminal refusals (reject-stale, conflict, validation-red, give-up, push-refused[-workflow] at merge and rebase stage, error) — exit 3 with their own reason even while S7 is unchanged; - validation-missing (the merge stopped waiting for Validate) — exit 3, with «no code change needed, re-apply S7 after a green Validate»; - rereview — printed, the wait goes on: the task is already back in S7 and the new round starts by itself; - a green merge has no sign in the catalog — the S8 label reports it (0). A label change keeps exit 0 and now carries the reason in its lines. The round anchor (latest S4/S7), the baseline for older comments, blocked and review-4 are unchanged. «stale» and «merge-conflict» stay in PIPELINE_EVENTS: they are the fallback for pre-#752 bodies, and process-metrics.mjs imports them by kind. Tests use the actual bodies from commentFor/describePushRefusal; each of the four new cases is red on the original wait-verdict.mjs. The new mutant wait-verdict-merge-outcomes-unknown was checked by hand: its guard goes red. AGENTS.md, where the exit codes are described, is updated to match. Issue: #768 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:
@@ -179,8 +179,11 @@ instead of ending the session.** The label starts the pipeline by itself. Poll
|
||||
with `node scripts/wait-verdict.mjs --issue NN [--sha <tip>]` (#496): it watches
|
||||
the label and the pipeline's comments every 90 s, at most 110 times, prints only
|
||||
on a change and exits 0 on a new label, 3 on an event that needs a hand, 4 on
|
||||
timeout. Watch the **label**, not the comment. Do not wait while `blocked` is
|
||||
set.
|
||||
timeout. A failed merge after a green verdict (outcomes of
|
||||
`merge-candidate.mjs`, #768) exits 3 while the label still reads `S7`, and its
|
||||
reason is printed when the label has already moved; «diff changed on rebase» is
|
||||
printed and the wait goes on — the new review round starts by itself. Watch the
|
||||
**label**, not the comment. Do not wait while `blocked` is set.
|
||||
|
||||
| Now reads | What happened | What you do |
|
||||
|---|---|---|
|
||||
|
||||
@@ -12382,6 +12382,18 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: ' .filter((c) => c.event); // mutant: every historical event belongs to the current round',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'wait-verdict-merge-outcomes-unknown',
|
||||
guard: 'node --test --test-name-pattern="#768" test/wait-verdict.test.mjs',
|
||||
because: '#768: merge-candidate outcomes are recognised through its OUTCOME_SIGNS catalog; without it '
|
||||
+ 'a red or stuck candidate keeps the author waiting in S7 until the timeout, and after the move to S6 '
|
||||
+ 'only the label change is printed, the reason is lost',
|
||||
patches: [{
|
||||
file: 'scripts/wait-verdict.mjs',
|
||||
find: ' const sign = outcomeOf(text);',
|
||||
replace: ' const sign = null; // mutant: merge-candidate outcomes are not recognised',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'release-gate-counts-cancelled-runs',
|
||||
guard: 'node --test test/release-gate.test.mjs',
|
||||
|
||||
+71
-13
@@ -6,31 +6,43 @@
|
||||
// и рассуждением «ничего не изменилось» — десятки пустых ходов на одно ревью.
|
||||
// Этот скрипт делает опрос сам и ГОВОРИТ только при смене состояния: одинаковое
|
||||
// состояние не будит никого. Что доставляется: смена статусной метки (вердикт),
|
||||
// отказ конвейера (комментарий «Ревью не запускалось» / «Слияние отменено» /
|
||||
// «Автоматическое ревью не отработало»), маршрут вердикта show (#726: трек
|
||||
// повышен до ask / вопрос владельцу), `blocked`, `review-4`, а при `--sha` —
|
||||
// исход Validate на этом SHA. Исторические комментарии до последнего запроса
|
||||
// S4/S7 образуют baseline: иначе новый раунд немедленно завершался по старому
|
||||
// failure. Событие текущего раунда, даже опубликованное до запуска waiter,
|
||||
// доставляется сразу (#546).
|
||||
// отказ конвейера (комментарий «Ревью не запускалось» / «Автоматическое ревью не
|
||||
// отработало»), исход слияния кандидата (#768: по каталогу `OUTCOME_SIGNS`
|
||||
// `merge-candidate.mjs` — отменено, конфликт, красный или не дождавшийся
|
||||
// Validate кандидат, ушедший dev, отказ push, сбой шага), маршрут вердикта show
|
||||
// (#726: трек повышен до ask / вопрос владельцу), `blocked`, `review-4`, а при
|
||||
// `--sha` — исход Validate на этом SHA. Исторические комментарии до последнего
|
||||
// запроса S4/S7 образуют baseline: иначе новый раунд немедленно завершался по
|
||||
// старому failure. Событие текущего раунда, даже опубликованное до запуска
|
||||
// waiter, доставляется сразу (#546).
|
||||
//
|
||||
// Скрипт НИЧЕГО не пишет: ни меток, ни комментариев, ни запусков. Новое ревью
|
||||
// или релиз начинаются только по текущей авторизации человека.
|
||||
//
|
||||
// node scripts/wait-verdict.mjs --issue 437 [--sha <tip>] [--interval 90] [--max 110]
|
||||
//
|
||||
// Коды выхода: 0 — статус сменился (вердикт есть, читать метку и комментарий);
|
||||
// Коды выхода: 0 — статус сменился (вердикт есть, читать метку и комментарий;
|
||||
// причина исхода слияния печатается и тогда, когда метка уже ушла в S6);
|
||||
// 3 — доставлено событие, требующее действия (отказ конвейера, конфликт,
|
||||
// blocked, review-4, красный Validate); 4 — лимит ожидания, состояние прежнее;
|
||||
// 2 — ошибка вызова.
|
||||
// неудавшееся слияние, blocked, review-4, красный Validate) — даже если метка
|
||||
// ещё S7; 4 — лимит ожидания, состояние прежнее; 2 — ошибка вызова. Исход
|
||||
// `rereview` (дифф изменился при ребейзе) печатается, но ожидание идёт дальше:
|
||||
// новый заход ревью конвейер запускает сам. Успешное слияние признака в
|
||||
// каталоге не имеет — о нём говорит метка S8 (код 0).
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
import { outcomeOf } from './merge-candidate.mjs';
|
||||
|
||||
export const REVIEW_LABELS = ['S4-spec-review', 'S7-code-review'];
|
||||
const STATUS = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready', 'S6-in-progress', 'S7-code-review', 'S8-merged'];
|
||||
|
||||
/** Комментарии конвейера, которые требуют действия автора или владельца. */
|
||||
/**
|
||||
* Комментарии конвейера, которые требуют действия автора или владельца.
|
||||
* Исходы `merge-candidate.mjs` узнаются раньше, по его каталогу
|
||||
* (`OUTCOME_EVENTS` ниже); `stale` и `merge-conflict` здесь — префиксы для тел
|
||||
* до #752 и признаки, которые по `kind` импортирует `process-metrics.mjs`.
|
||||
*/
|
||||
export const PIPELINE_EVENTS = [
|
||||
{ re: /^\*\*Ревью не запускалось:\*\*/m, kind: 'conflict', text: 'конвейер: ветка не ребейзится на dev — конфликт разрешает автор' },
|
||||
{ re: /^\*\*Слияние отменено/m, kind: 'stale', text: 'конвейер: слияние отменено — вершина ветки ушла от проверенного SHA (#312)' },
|
||||
@@ -44,6 +56,50 @@ export const PIPELINE_EVENTS = [
|
||||
{ re: /^Конвейер ревью не запущен:/m, kind: 'refused', text: 'конвейер отказал (blocked/review-4) — читать комментарий' },
|
||||
];
|
||||
|
||||
/**
|
||||
* #768: исходы слияния кандидата — по общему каталогу `OUTCOME_SIGNS`
|
||||
* (`outcomeOf`), а не копиями регулярок: прежний список знал два заголовка из
|
||||
* одиннадцати, и отказ в окне «комментарий уже есть, метка ещё S7» ждал до
|
||||
* лимита, а после смены метки печаталась одна смена статуса без причины.
|
||||
*
|
||||
* Ключ — `action` признака, внутри — `stage` (`merge` после зелёного вердикта,
|
||||
* `rebase` — страж ребейза до ревью). `code`: 3 — нужно действие автора или
|
||||
* владельца; `null` — исход не терминальный (`rereview`: задача уже снова в
|
||||
* S7, новый заход ревью конвейер запускает сам) — строка печатается, ожидание
|
||||
* идёт дальше. Новый исход каталога краснит тест #768, пока у него нет строки
|
||||
* в таблице теста; без строки здесь он доставляется общим текстом с кодом 3.
|
||||
*/
|
||||
export const OUTCOME_EVENTS = Object.freeze({
|
||||
'reject-stale': { kind: 'stale', code: 3, text: 'конвейер: слияние отменено — вершина ветки ушла от проверенного SHA (#312)' },
|
||||
conflict: { kind: 'merge-conflict', code: 3, text: 'конвейер: вердикт зелёный, слияние конфликтует — ребейз (rebase-on-dev.mjs) и снова S7' },
|
||||
'validation-red': { kind: 'validation-red', code: 3, text: 'конвейер: вердикт зелёный, но кандидат после ребейза на dev красный — разобрать прогон Validate и снова S7' },
|
||||
'validation-missing': { kind: 'validation-missing', code: 3, text: 'конвейер: вердикт зелёный, Validate на кандидате не дождались — слияния нет; после зелёного Validate на кандидате снова S7, править код не нужно' },
|
||||
'give-up': { kind: 'give-up', code: 3, text: 'конвейер: вердикт зелёный, dev движется быстрее слияния — снова S7, когда dev успокоится' },
|
||||
'push-refused-workflow': {
|
||||
merge: { kind: 'push-refused-workflow', code: 3, text: 'конвейер: вердикт зелёный, но GitHub не принял push кандидата — он меняет workflow-файл, у токена нет права: ребейз и push делает автор либо право выдаёт владелец (#705)' },
|
||||
rebase: { kind: 'push-refused-workflow', code: 3, text: 'конвейер: ревью не запускалось — GitHub не принял push ребейза, ветка меняет workflow-файл: ребейз и push делает автор либо право выдаёт владелец (#705)' },
|
||||
},
|
||||
'push-refused': {
|
||||
merge: { kind: 'push-refused', code: 3, text: 'конвейер: вердикт зелёный, но GitHub отклонил push в dev — причина в комментарии, устранить и снова S7 (#705)' },
|
||||
rebase: { kind: 'push-refused', code: 3, text: 'конвейер: ревью не запускалось — GitHub отклонил push ребейза, причина в комментарии (#705)' },
|
||||
},
|
||||
error: { kind: 'merge-error', code: 3, text: 'конвейер: вердикт зелёный, шаг слияния упал — сбой в комментарии, после разбора снова S7' },
|
||||
rereview: { kind: 'rereview', code: null, text: 'конвейер: дифф изменился при ребейзе на dev — вердикт к нему не применим, новый заход ревью запускается сам; ждём его вердикт' },
|
||||
});
|
||||
|
||||
/** Событие по телу комментария: исход слияния по каталогу, иначе свой комментарий конвейера. */
|
||||
export function pipelineEventOf(body) {
|
||||
const text = String(body || '');
|
||||
const sign = outcomeOf(text);
|
||||
if (sign) {
|
||||
const entry = OUTCOME_EVENTS[sign.action];
|
||||
const event = entry && (entry.kind ? entry : entry[sign.stage]);
|
||||
// Исход, которого таблица ещё не знает: молчать нельзя — это и был дефект #768.
|
||||
return event || { kind: sign.action, code: 3, text: `конвейер: исход слияния \`${sign.action}\` — читать комментарий` };
|
||||
}
|
||||
return PIPELINE_EVENTS.find((e) => e.re.test(text)) || null;
|
||||
}
|
||||
|
||||
/** Последнее применение S4/S7 — устойчивый якорь текущего раунда ревью. */
|
||||
export function reviewRequestFromEvents(events = []) {
|
||||
const requests = events
|
||||
@@ -70,7 +126,7 @@ export function stateOf(snapshot) {
|
||||
const labels = snapshot.labels || [];
|
||||
const status = STATUS.find((l) => labels.includes(l)) || null;
|
||||
const events = (snapshot.comments || [])
|
||||
.map((c) => ({ id: c.id, at: c.createdAt, event: PIPELINE_EVENTS.find((e) => e.re.test(String(c.body || ''))) }))
|
||||
.map((c) => ({ id: c.id, at: c.createdAt, event: pipelineEventOf(c.body) }))
|
||||
.filter((c) => c.event && eventBelongsToReview(c, snapshot.reviewRequest));
|
||||
const last = events.at(-1) || null;
|
||||
return {
|
||||
@@ -97,7 +153,9 @@ export function decide(prev, next) {
|
||||
}
|
||||
if (next.lastEventId && (!prev || prev.lastEventId !== next.lastEventId)) {
|
||||
lines.push(next.lastEvent.text);
|
||||
if (code === null) code = 3;
|
||||
// `code: null` — исход не терминальный (rereview): сказать и ждать дальше.
|
||||
const eventCode = next.lastEvent.code === undefined ? 3 : next.lastEvent.code;
|
||||
if (code === null && eventCode !== null) code = eventCode;
|
||||
}
|
||||
if (next.exhausted && (!prev || !prev.exhausted)) { lines.push('review-4: лимит циклов — решение владельца'); code = 3; }
|
||||
if (next.blocked && (!prev || !prev.blocked)) { lines.push('blocked: задача ждёт владельца, ждать вердикт бессмысленно'); code = 3; }
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { decide, reviewRequestFromEvents, stateOf, waitForVerdict } from '../scripts/wait-verdict.mjs';
|
||||
import { reviewRoute, routeComment } from '../scripts/process-track.mjs';
|
||||
import { OUTCOME_SIGNS, PUSH_REFUSAL, commentFor, describePushRefusal } from '../scripts/merge-candidate.mjs';
|
||||
|
||||
// #496: ожидание детерминировано — одинаковое состояние молчит, смена метки и
|
||||
// события конвейера доставляются один раз, ничего не пишется.
|
||||
@@ -169,3 +170,106 @@ test('#726 AC7: вопрос владельцу с blocked доставлен,
|
||||
const both = { id: 'b', createdAt: '4', body: routeBody({ spent: 1, route: 'reclassify', criterion: 'surfaces', confirmed: true }, '2') };
|
||||
assert.equal(stateOf(snap(['S6-in-progress', 'review-4', 'blocked'], [both])).lastEvent.kind, 'exhausted');
|
||||
});
|
||||
|
||||
// #768: исходы слияния кандидата — тела, которые пишет сам merge-candidate.mjs
|
||||
// (commentFor / describePushRefusal), а не копии строк. Терминальный отказ —
|
||||
// код 3 уже при неизменной S7; rereview — строка без завершения; слито —
|
||||
// не событие, его сообщает метка S8.
|
||||
const MERGE_CTX = {
|
||||
material: 'a'.repeat(40), actual: 'b'.repeat(40), candidate: 'c'.repeat(40), devNow: 'd'.repeat(40),
|
||||
branch: 'issue/7-x', ref: 'dev', runUrl: 'https://github.com/o/r/actions/runs/1', attempt: 3, error: 'git push dev: boom',
|
||||
refusal: { kind: PUSH_REFUSAL.workflow, reason: 'r', files: ['.github/workflows/x.yml'], stderr: 'e' }, pipelineUrl: 'https://run/1',
|
||||
};
|
||||
const REQUEST = { id: 'request-2', at: '2026-10-01T10:00:00Z', label: 'S7-code-review' };
|
||||
const rejectedPush = (reason) => `To https://github.com/o/r\n ! [remote rejected] 0123abcd -> issue/7-x (${reason})\nerror: failed to push some refs to 'https://github.com/o/r'`;
|
||||
const rebaseRefusal = (reason) => describePushRefusal(rejectedPush(reason), { ref: 'issue/7-x', branch: 'issue/7-x', candidate: 'c'.repeat(40), stage: 'rebase', pipelineUrl: 'https://run/1' }).comment;
|
||||
// [название, тело, ожидаемый kind (null — не событие), код при неизменной S7 (null — ждать дальше), фрагмент строки]
|
||||
const OUTCOME_TABLE = [
|
||||
['reject-stale', commentFor('reject-stale', MERGE_CTX), 'stale', 3, 'слияние отменено'],
|
||||
['conflict', commentFor('conflict', MERGE_CTX), 'merge-conflict', 3, 'слияние конфликтует'],
|
||||
['validation-red', commentFor('validation-red', MERGE_CTX), 'validation-red', 3, 'кандидат после ребейза на dev красный'],
|
||||
['validation-missing', commentFor('validation-missing', MERGE_CTX), 'validation-missing', 3, 'Validate на кандидате не дождались'],
|
||||
['give-up', commentFor('give-up', MERGE_CTX), 'give-up', 3, 'dev движется быстрее слияния'],
|
||||
['push-refused-workflow/merge', commentFor('push-refused-workflow', { ...MERGE_CTX, stage: 'merge' }), 'push-refused-workflow', 3, 'GitHub не принял push кандидата'],
|
||||
['push-refused-workflow/rebase', rebaseRefusal('refusing to allow an OAuth App to create or update workflow `.github/workflows/validate.yml` without `workflow` scope'), 'push-refused-workflow', 3, 'ревью не запускалось — GitHub не принял push ребейза'],
|
||||
['push-refused/merge', commentFor('push-refused', { ...MERGE_CTX, stage: 'merge', refusal: { kind: PUSH_REFUSAL.remote, reason: 'protected branch hook declined', stderr: 'e' } }), 'push-refused', 3, 'GitHub отклонил push в dev'],
|
||||
['push-refused/rebase', rebaseRefusal('protected branch hook declined'), 'push-refused', 3, 'GitHub отклонил push ребейза'],
|
||||
['error', commentFor('error', { error: 'boom' }), 'merge-error', 3, 'шаг слияния упал'],
|
||||
['rereview', commentFor('rereview', MERGE_CTX), 'rereview', null, 'новый заход ревью запускается сам'],
|
||||
['push', commentFor('push', MERGE_CTX), null, null, null],
|
||||
['fast-forward', commentFor('fast-forward', MERGE_CTX), null, null, null],
|
||||
];
|
||||
|
||||
test('#768 исходы слияния: таблица тел commentFor → событие и код при неизменной S7', () => {
|
||||
for (const [name, body, kind, code, fragment] of OUTCOME_TABLE) {
|
||||
assert.ok(body, `${name}: тело не пусто`);
|
||||
const comment = { id: name, createdAt: '2026-10-01T10:05:00Z', body };
|
||||
const state = stateOf({ ...snap(['S7-code-review'], [comment]), reviewRequest: REQUEST });
|
||||
assert.equal(state.lastEvent?.kind ?? null, kind, `${name}: вид события`);
|
||||
const d = decide(stateOf({ ...snap(['S7-code-review']), reviewRequest: REQUEST }), state);
|
||||
assert.equal(d.code, code, `${name}: код`);
|
||||
assert.equal(d.done, code !== null, `${name}: завершение`);
|
||||
if (fragment) assert.ok(d.lines.some((line) => line.includes(fragment)), `${name}: строка «${fragment}» — ${d.lines.join(' | ')}`);
|
||||
else assert.deepEqual(d.lines, [], `${name}: слито — не событие, ждём метку S8`);
|
||||
}
|
||||
// Каталог merge-candidate.mjs целиком покрыт: новый исход без своей строки здесь краснеет.
|
||||
const covered = new Set(OUTCOME_TABLE.filter(([, , kind]) => kind).map(([name]) => name));
|
||||
for (const sign of OUTCOME_SIGNS) {
|
||||
assert.ok(covered.has(`${sign.action}/${sign.stage}`) || covered.has(sign.action), `${sign.action}/${sign.stage} — нет строки в таблице`);
|
||||
}
|
||||
});
|
||||
|
||||
test('#768 исход текущего раунда, опубликованный до запуска waiter, — код 3 на первом опросе', async () => {
|
||||
const red = { id: 'red', createdAt: '2026-10-01T10:05:00Z', body: commentFor('validation-red', MERGE_CTX) };
|
||||
const lines = []; let slept = 0;
|
||||
const code = await waitForVerdict({
|
||||
readSnapshot: async () => ({ ...snap(['S7-code-review'], [red]), reviewRequest: REQUEST }),
|
||||
intervalMs: 1, maxTicks: 3, sleep: async () => { slept++; }, log: (line) => lines.push(line),
|
||||
});
|
||||
assert.equal(code, 3);
|
||||
assert.equal(slept, 0, 'ожидания нет: исход уже есть');
|
||||
assert.ok(lines.some((line) => line.includes('кандидат после ребейза на dev красный')));
|
||||
// Waiter, запущенный уже после возврата в S6, причину тоже называет, а не «ждать нечего».
|
||||
const late = decide(null, stateOf({ ...snap(['S6-in-progress'], [red]), reviewRequest: REQUEST }));
|
||||
assert.equal(late.code, 3);
|
||||
assert.ok(late.lines.some((line) => line.includes('кандидат после ребейза на dev красный')));
|
||||
assert.ok(!late.lines.some((line) => line.includes('ждать нечего')));
|
||||
});
|
||||
|
||||
test('#768 исход прежнего раунда — baseline; тот же исход текущего раунда доставляется', async () => {
|
||||
const old = { id: 'old', createdAt: '2026-09-30T10:00:00Z', body: commentFor('give-up', MERGE_CTX) };
|
||||
const fresh = { id: 'fresh', createdAt: '2026-10-01T10:30:00Z', body: commentFor('give-up', MERGE_CTX) };
|
||||
const states = [
|
||||
{ ...snap(['S7-code-review'], [old]), reviewRequest: REQUEST },
|
||||
{ ...snap(['S7-code-review'], [old]), reviewRequest: REQUEST },
|
||||
{ ...snap(['S7-code-review'], [old, fresh]), reviewRequest: REQUEST },
|
||||
];
|
||||
let i = 0; const lines = []; let slept = 0;
|
||||
const code = await waitForVerdict({
|
||||
readSnapshot: async () => states[Math.min(i++, states.length - 1)],
|
||||
intervalMs: 1, maxTicks: 5, sleep: async () => { slept++; }, log: (line) => lines.push(line),
|
||||
});
|
||||
assert.equal(code, 3);
|
||||
assert.equal(slept, 2, 'старый исход не будит: ждём, пока не придёт исход текущего раунда');
|
||||
assert.equal(lines.filter((line) => line.includes('dev движется быстрее слияния')).length, 1);
|
||||
// rereview снова ставит S7: его комментарий оказывается до нового якоря и не повторяется.
|
||||
const rereview = { id: 'rr', createdAt: '2026-10-01T10:05:00Z', body: commentFor('rereview', MERGE_CTX) };
|
||||
const relabeled = stateOf({ ...snap(['S7-code-review'], [rereview]), reviewRequest: { ...REQUEST, id: 'request-3', at: '2026-10-01T10:06:00Z' } });
|
||||
assert.equal(relabeled.lastEvent, null);
|
||||
});
|
||||
|
||||
test('#768 сменившаяся метка: S6 — код 0 и причина в строках; S8 со «слито» — код 0 без отказа', () => {
|
||||
const before = stateOf({ ...snap(['S7-code-review']), reviewRequest: REQUEST });
|
||||
for (const [name, body, kind, code, fragment] of OUTCOME_TABLE) {
|
||||
if (code === null) continue;
|
||||
const comment = { id: name, createdAt: '2026-10-01T10:05:00Z', body };
|
||||
const d = decide(before, stateOf({ ...snap(['S6-in-progress'], [comment]), reviewRequest: REQUEST }));
|
||||
assert.equal(d.code, 0, `${name}: смена метки сохраняет код 0`);
|
||||
assert.ok(d.lines.some((line) => line.includes('S7-code-review → S6-in-progress')), name);
|
||||
assert.ok(d.lines.some((line) => line.includes(fragment)), `${name}: причина не потеряна (${kind})`);
|
||||
}
|
||||
const merged = { id: 'm', createdAt: '2026-10-01T10:05:00Z', body: commentFor('push', MERGE_CTX) };
|
||||
const done = decide(before, stateOf({ ...snap(['S8-merged'], [merged]), reviewRequest: REQUEST }));
|
||||
assert.equal(done.code, 0);
|
||||
assert.deepEqual(done.lines.map((line) => line.replace(/^\[[^\]]*\] /, '')), ['метка: S7-code-review → S8-merged']);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user