mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-04 05:41:34 +00:00
fix(process): bind verdict wait to current review round (#546)
Issue: #546 User-Visible: no
This commit is contained in:
@@ -245,7 +245,10 @@ the command timeout. Do the polling with `node scripts/wait-verdict.mjs --issue
|
||||
cancelled merge, failed run) and optionally Validate on the SHA, prints only when
|
||||
the state changes and exits 0 on a new label, 3 on an event that needs a hand,
|
||||
4 on timeout — the same 90 s × 30 without a model turn per tick. It writes
|
||||
nothing. Watch the **label**, not the comment: the label is the state,
|
||||
nothing. Pipeline comments older than the latest application of `S4`/`S7` are
|
||||
the baseline, not an outcome of the new round; an outcome from the current round
|
||||
which already exists when the waiter starts is still delivered immediately (#546).
|
||||
Watch the **label**, not the comment: the label is the state,
|
||||
the comment only explains it. Do not wait at all while `blocked` is set — the task
|
||||
is waiting on the owner, not on the reviewer. On exhausting the attempts, stop and
|
||||
tell the owner: a failed run leaves the label where it was, forever.
|
||||
|
||||
+4
-2
@@ -962,8 +962,10 @@ npm ci, Python и Chromium, оставаясь исполненной job: до
|
||||
он существует только в момент своего хода, поэтому ожидание это опрос: раз в 90
|
||||
секунд, не более 30 попыток — `node scripts/wait-verdict.mjs --issue NN` делает его
|
||||
детерминированно и говорит только при смене состояния (#496). Смотреть на метку, а
|
||||
не на комментарий: метка и есть состояние. При `blocked` не ждать — задача ждёт
|
||||
владельца.
|
||||
не на комментарий: метка и есть состояние. Комментарии конвейера до последнего
|
||||
применения `S4`/`S7` считаются историческим baseline, а уже опубликованный исход
|
||||
текущего раунда доставляется сразу при первом опросе (#546). При `blocked` не
|
||||
ждать — задача ждёт владельца.
|
||||
|
||||
**После прогона ревью метка меняется всегда.** Инвариант появился не сразу: первая
|
||||
редакция при конфликте слияния оставляла метку на месте, и это оказалось тупиком —
|
||||
|
||||
@@ -8653,6 +8653,17 @@ const MUTANT_DEFINITIONS = [
|
||||
+ ' # The store is the durable authority (#335): a drop that\n',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'wait-verdict-reuses-historical-failure',
|
||||
guard: 'node --test --test-name-pattern="#546" test/wait-verdict.test.mjs',
|
||||
because: 'the first snapshot must baseline pipeline comments older than the latest S4/S7 request; '
|
||||
+ 'otherwise every restarted review immediately exits on the previous round failure (#546 AC1/AC2)',
|
||||
patches: [{
|
||||
file: 'scripts/wait-verdict.mjs',
|
||||
find: ' .filter((c) => c.event && eventBelongsToReview(c, snapshot.reviewRequest));',
|
||||
replace: ' .filter((c) => c.event); // mutant: every historical event belongs to the current round',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'release-gate-counts-cancelled-runs',
|
||||
guard: 'node --test test/release-gate.test.mjs',
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
// состояние не будит никого. Что доставляется: смена статусной метки (вердикт),
|
||||
// отказ конвейера (комментарий «Ревью не запускалось» / «Слияние отменено» /
|
||||
// «Автоматическое ревью не отработало»), `blocked`, `review-4`, а при `--sha` —
|
||||
// исход Validate на этом SHA.
|
||||
// исход Validate на этом SHA. Исторические комментарии до последнего запроса
|
||||
// S4/S7 образуют baseline: иначе новый раунд немедленно завершался по старому
|
||||
// failure. Событие текущего раунда, даже опубликованное до запуска waiter,
|
||||
// доставляется сразу (#546).
|
||||
//
|
||||
// Скрипт НИЧЕГО не пишет: ни меток, ни комментариев, ни запусков. Новое ревью
|
||||
// или релиз начинаются только по текущей авторизации человека.
|
||||
@@ -36,13 +39,34 @@ export const PIPELINE_EVENTS = [
|
||||
{ re: /^Конвейер ревью не запущен:/m, kind: 'refused', text: 'конвейер отказал (blocked/review-4) — читать комментарий' },
|
||||
];
|
||||
|
||||
/** Последнее применение S4/S7 — устойчивый якорь текущего раунда ревью. */
|
||||
export function reviewRequestFromEvents(events = []) {
|
||||
const requests = events
|
||||
.filter((event) => event?.event === 'labeled' && REVIEW_LABELS.includes(event?.label?.name))
|
||||
.map((event) => ({
|
||||
id: String(event.id || event.node_id || event.createdAt || event.created_at || ''),
|
||||
at: event.createdAt || event.created_at || null,
|
||||
label: event.label.name,
|
||||
}))
|
||||
.filter((request) => Number.isFinite(Date.parse(String(request.at || ''))));
|
||||
requests.sort((a, b) => Date.parse(a.at) - Date.parse(b.at) || a.id.localeCompare(b.id));
|
||||
return requests.at(-1) || null;
|
||||
}
|
||||
|
||||
function eventBelongsToReview(comment, request) {
|
||||
if (!request) return true; // Совместимость с чистыми/старыми snapshot без timeline.
|
||||
const eventAt = Date.parse(String(comment.at || ''));
|
||||
const requestAt = Date.parse(String(request.at || ''));
|
||||
return Number.isFinite(eventAt) && Number.isFinite(requestAt) && eventAt >= requestAt;
|
||||
}
|
||||
|
||||
/** Снимок → нормализованное состояние. */
|
||||
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 || ''))) }))
|
||||
.filter((c) => c.event);
|
||||
.filter((c) => c.event && eventBelongsToReview(c, snapshot.reviewRequest));
|
||||
const last = events.at(-1) || null;
|
||||
return {
|
||||
status,
|
||||
@@ -50,6 +74,7 @@ export function stateOf(snapshot) {
|
||||
exhausted: labels.includes('review-4'),
|
||||
lastEventId: last ? String(last.id) : null,
|
||||
lastEvent: last ? last.event : null,
|
||||
reviewRequest: snapshot.reviewRequest || null,
|
||||
validate: snapshot.validate || null,
|
||||
};
|
||||
}
|
||||
@@ -115,9 +140,12 @@ function gh(args) {
|
||||
export function ghSnapshotReader({ number, repo, sha }) {
|
||||
return async () => {
|
||||
const view = gh(['issue', 'view', String(number), '--repo', repo, '--json', 'labels,comments']);
|
||||
const pages = gh(['api', '--paginate', '--slurp', `repos/${repo}/issues/${number}/events?per_page=100`]);
|
||||
const timelineEvents = Array.isArray(pages?.[0]) ? pages.flat() : (Array.isArray(pages) ? pages : []);
|
||||
const snapshot = {
|
||||
labels: (view.labels || []).map((l) => l.name),
|
||||
comments: (view.comments || []).map((c) => ({ id: c.id || c.url || c.createdAt, createdAt: c.createdAt, body: c.body })),
|
||||
reviewRequest: reviewRequestFromEvents(timelineEvents),
|
||||
};
|
||||
if (sha) {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { decide, stateOf, waitForVerdict } from '../scripts/wait-verdict.mjs';
|
||||
import { decide, reviewRequestFromEvents, stateOf, waitForVerdict } from '../scripts/wait-verdict.mjs';
|
||||
|
||||
// #496: ожидание детерминировано — одинаковое состояние молчит, смена метки и
|
||||
// события конвейера доставляются один раз, ничего не пишется.
|
||||
@@ -56,3 +56,71 @@ test('лимит ожидания — код 4 и одна строка (#496)',
|
||||
assert.equal(lines.length, 2);
|
||||
assert.match(lines[1], /лимит ожидания \(3 × 0 с\)/);
|
||||
});
|
||||
|
||||
test('старый failure до нового запроса ревью — baseline, ожидание продолжается (#546)', async () => {
|
||||
const oldFailure = {
|
||||
id: 'old', createdAt: '2026-09-08T10:00:00Z',
|
||||
body: 'Автоматическое ревью не отработало: [прогон](old).',
|
||||
};
|
||||
const currentRequest = { id: 'request-2', at: '2026-09-12T10:00:00Z', label: 'S7-code-review' };
|
||||
const snapshot = { ...snap(['S7-code-review'], [oldFailure]), reviewRequest: currentRequest };
|
||||
const lines = []; let slept = 0;
|
||||
const code = await waitForVerdict({
|
||||
readSnapshot: async () => snapshot,
|
||||
intervalMs: 1, maxTicks: 2, sleep: async () => { slept++; }, log: (line) => lines.push(line),
|
||||
});
|
||||
assert.equal(code, 4);
|
||||
assert.equal(slept, 1);
|
||||
assert.equal(lines.some((line) => line.includes('прогон ревью упал')), false);
|
||||
});
|
||||
|
||||
test('failure текущего раунда, опубликованный до запуска waiter, виден на первом poll (#546)', async () => {
|
||||
const currentFailure = {
|
||||
id: 'current', createdAt: '2026-09-12T10:05:00Z',
|
||||
body: 'Автоматическое ревью не отработало: [прогон](current).',
|
||||
};
|
||||
const snapshot = {
|
||||
...snap(['S7-code-review'], [currentFailure]),
|
||||
reviewRequest: { id: 'request-2', at: '2026-09-12T10:00:00Z', label: 'S7-code-review' },
|
||||
};
|
||||
const lines = []; let slept = 0;
|
||||
const code = await waitForVerdict({
|
||||
readSnapshot: async () => snapshot,
|
||||
intervalMs: 1, maxTicks: 2, sleep: async () => { slept++; }, log: (line) => lines.push(line),
|
||||
});
|
||||
assert.equal(code, 3);
|
||||
assert.equal(slept, 0);
|
||||
assert.ok(lines.some((line) => line.includes('прогон ревью упал')));
|
||||
});
|
||||
|
||||
test('якорь раунда — последнее применение любой review-метки (#546)', () => {
|
||||
const request = reviewRequestFromEvents([
|
||||
{ id: 1, event: 'labeled', created_at: '2026-09-10T09:00:00Z', label: { name: 'S4-spec-review' } },
|
||||
{ id: 2, event: 'labeled', created_at: '2026-09-10T10:00:00Z', label: { name: 'P1' } },
|
||||
{ id: 3, event: 'unlabeled', created_at: '2026-09-10T11:00:00Z', label: { name: 'S4-spec-review' } },
|
||||
{ id: 4, event: 'labeled', created_at: '2026-09-12T09:00:00Z', label: { name: 'S7-code-review' } },
|
||||
]);
|
||||
assert.deepEqual(request, { id: '4', at: '2026-09-12T09:00:00Z', label: 'S7-code-review' });
|
||||
});
|
||||
|
||||
test('новые outcome и owner blocker текущего раунда не скрываются baseline-фильтром (#546)', () => {
|
||||
const request = { id: 'request', at: '2026-09-12T10:00:00Z', label: 'S7-code-review' };
|
||||
const stale = {
|
||||
id: 'stale', createdAt: '2026-09-12T10:05:00Z',
|
||||
body: '**Слияние отменено: ветка изменилась после проверенного материала (#312).**',
|
||||
};
|
||||
const event = decide(null, stateOf({ ...snap(['S7-code-review'], [stale]), reviewRequest: request }));
|
||||
assert.equal(event.code, 3);
|
||||
assert.ok(event.lines.some((line) => line.includes('слияние отменено')));
|
||||
|
||||
const blocker = decide(null, stateOf({ ...snap(['S7-code-review', 'blocked']), reviewRequest: request }));
|
||||
assert.equal(blocker.code, 3);
|
||||
assert.ok(blocker.lines.some((line) => line.includes('blocked')));
|
||||
|
||||
const verdict = decide(
|
||||
stateOf({ ...snap(['S7-code-review']), reviewRequest: request }),
|
||||
stateOf({ ...snap(['S8-merged']), reviewRequest: request }),
|
||||
);
|
||||
assert.equal(verdict.code, 0);
|
||||
assert.ok(verdict.lines.some((line) => line.includes('S7-code-review → S8-merged')));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user