diff --git a/.github/scripts/enqueue-guard.test.mjs b/.github/scripts/enqueue-guard.test.mjs index 05fae621e..6448fe89c 100644 --- a/.github/scripts/enqueue-guard.test.mjs +++ b/.github/scripts/enqueue-guard.test.mjs @@ -1117,3 +1117,46 @@ test('#602 (2026-11-01): an inline stop written before the PASS at this tip is n const r = run({ comments: [comment(report(), '2026-11-01T17:35:30Z', { id: 9103 })], reviewComments: [early] }) assert.equal(r.ok, true, r.reasons.join('; ')) }) + +// ---- P-479, 2026-11-03: the lift that answers a hold was itself thrown away. `parseReport` treats a line +// beginning `Verdict:` below the first line as this body's verdict (#643/#640 keep a quoted block from +// overwriting a conclusion only when the first line already is one), so a lift comment that quotes the PASS +// it answers parses as a conclusion, and the stop filter at `enqueue-rules.mjs:199` dropped the whole +// comment. The guard then told the author to answer the hold on the record — the one thing its own reader +// refused to see. The filter keeps a conclusion-shaped body when that body also asks to lift the hold; the +// second case is the counterweight: quoting a PASS without a lift phrase still clears nothing. + +test('P-479 (2026-11-03): a lift that quotes the PASS it answers still clears the stop', () => { + const lift = note({ + id: 6596858166, + login: 'hugo', + body: [ + 'The PASS for this head has landed. You may merge this tip now.', + '', + '`Verdict: PASS at ' + HEAD + '` names exactly this head.', + '', + 'Written-by: FlyBot Lead', + ].join('\n'), + at: '2026-11-03T06:15:25Z', + }) + const r = run({ + comments: [comment(report(), '2026-11-03T05:00:00Z', { id: 6596712952 }), note({ id: 6596354014, at: '2026-11-03T05:38:25Z' }), lift], + }) + assert.equal(r.ok, true, r.reasons.join('; ')) + assert.equal(r.stopRequest, null, 'the lift is the answer to the stop, so no stop stands') +}) + +test('P-479 (2026-11-03): a conclusion-shaped comment without a lift phrase does not clear the stop', () => { + const quoted = note({ + id: 6596858167, + login: 'hugo', + body: ['The PASS for this head has landed.', '', '`Verdict: PASS at ' + HEAD + '` names exactly this head.'].join('\n'), + at: '2026-11-03T06:15:25Z', + }) + const r = run({ + comments: [comment(report(), '2026-11-03T05:00:00Z', { id: 6596712952 }), note({ id: 6596354014, at: '2026-11-03T05:38:25Z' }), quoted], + }) + assert.equal(r.ok, false, 'a quoted conclusion is not an answer to the hold') + assert.match(r.reasons.join('; '), /asks not to merge/) + assert.match(r.reasons.join('; '), /6596354014/, 'the refusal still names the stop that stands') +}) diff --git a/.github/scripts/enqueue-rules.mjs b/.github/scripts/enqueue-rules.mjs index d386dea94..79890a918 100644 --- a/.github/scripts/enqueue-rules.mjs +++ b/.github/scripts/enqueue-rules.mjs @@ -182,6 +182,14 @@ const withoutQuotes = (text) => String(text ?? '') .replace(/"[^"\n]*"/g, ' ') .replace(/“[^”\n]*”/g, ' ') +// A body that parses as a conclusion but asks only to lift a stop — no ask to stop of its own. This is the +// lift that quotes the PASS it answers (#2246 on 2026-11-03); "record only — do not merge" in a PASS is the +// marker `recordOnlyAt` reads, so it is not one of these. +const responseOnly = (body) => { + const text = withoutQuotes(body) + return LIFT_REQUEST.test(text) && !STOP_REQUEST.test(text) && !HOLD_LINE.test(text) +} + // The stop request still standing, or null. `since` is the timestamp of the conclusion the decision used, in // ms; null reads the whole list (no anchor, or one that cannot be read — a guard that cannot find the PASS it // judged must not narrow what it refuses). @@ -195,8 +203,10 @@ export function stopRequestAt(reports, { author, since } = {}) { // whichever box it was typed in, and the callers already pass both (`enqueue-guard.mjs:69-71`, // `merge-gate.mjs:219-221`). A body carrying a conclusion is the conclusion, not a request: // "record only — do not merge" in a PASS is the marker `recordOnlyAt` already reads, and reading it - // here too would make every parked PASS its own stop. - .filter(({ r }) => !parseReport(r?.body).verdict) + // here too would make every parked PASS its own stop. The one exception is the answer to a stop: a + // lift that quotes the PASS it answers parses as a conclusion too (#2246 on 2026-11-03), and dropping + // it left the guard telling the author to do the one thing its own reader refused to see. + .filter(({ r }) => !parseReport(r?.body).verdict || responseOnly(r?.body)) .filter(({ t }) => after == null || t > after) .sort((a, b) => a.t - b.t || a.i - b.i) let stop = null