feat(review): a WARN opens the lock only when every counting MAJOR is answered

This commit is contained in:
Kjell Tore Guttormsen 2026-11-02 15:45:37 +01:00
commit ce7dbc27a9
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
8 changed files with 1044 additions and 64 deletions

View file

@ -18,7 +18,9 @@
// other than HEAD
// suite `npm test` exits 0 at HEAD (run for real, never read from a log)
// review lib/review/verdict-gate.mjs on the release's review.md: BLOCK
// stops (1), undecided stops (2)
// stops (1), undecided stops (2); an ALLOW/WARN with a counting
// MAJOR not answered in the top-level approval log
// (`decideOpenFindings`, `review-unanswered:`) is not ready (1)
// coverage the review is of THIS history: its `scope_sha_end` is HEAD or an
// ancestor of HEAD, and the commits after it touch only the release
// bookkeeping (the CHANGELOG, the version in the version files,
@ -39,11 +41,12 @@ import { existsSync, readFileSync, realpathSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { decideVerdict } from '../lib/review/verdict-gate.mjs';
import { decideVerdict, decideOpenFindings } from '../lib/review/verdict-gate.mjs';
import { reviewedSha } from '../lib/review/reviewed-commit.mjs';
import { commandEnv } from '../lib/util/command-env.mjs';
import { DEFAULT_TIMEOUT_MS, stoppedBy } from '../lib/verification/criteria-runner.mjs';
import { isMain } from '../lib/util/is-main.mjs';
import { releaseSection, parseDoors } from '../lib/util/doors.mjs';
const PLUGIN_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@ -76,55 +79,12 @@ export function withoutVersion(text) {
}
return JSON.stringify(o);
}
const DOOR_LINE_RE = /^- (one-way|two-way): \S/;
const git = (cwd, args) => spawnSync('git', args, { cwd, encoding: 'utf8' });
/**
* The CHANGELOG section for `version`, heading line excluded, or null. Matches
* `## v1.2.3 …`, `## [1.2.3] …` and `## 1.2.3 …` exactly: 1.1.0 is not 1.10.0
* and not 1.1.0-pre.
*/
export function releaseSection(text, version) {
if (typeof text !== 'string' || !version) return null;
const lines = text.split('\n');
const headingVersion = (line) => {
const m = line.match(/^##\s+\[?v?([^\]\s(]+)\]?/);
return m ? m[1] : null;
};
let start = -1;
for (let i = 0; i < lines.length; i++) {
if (/^##\s/.test(lines[i]) && headingVersion(lines[i]) === version) { start = i + 1; break; }
}
if (start === -1) return null;
let end = lines.length;
for (let i = start; i < lines.length; i++) {
if (/^##\s/.test(lines[i])) { end = i; break; }
}
return lines.slice(start, end).join('\n');
}
/**
* The `### Doors` list of a release section. Top-level `- ` lines must read
* `- one-way: …` or `- two-way: …`; indented lines continue the entry above.
* @returns {{ found: boolean, entries: Array<{door: string, text: string}>, bad: string[] }}
*/
export function parseDoors(section) {
const lines = String(section ?? '').split('\n');
const start = lines.findIndex((l) => /^###\s+Doors\s*$/.test(l));
if (start === -1) return { found: false, entries: [], bad: [] };
const entries = [];
const bad = [];
for (let i = start + 1; i < lines.length; i++) {
const line = lines[i];
if (/^#{2,3}\s/.test(line)) break;
if (!line.startsWith('- ')) continue;
const m = line.match(DOOR_LINE_RE);
if (m) entries.push({ door: m[1], text: line.slice(m[0].length - 1).trim() });
else bad.push(line);
}
return { found: true, entries, bad };
}
// The CHANGELOG door helpers live in lib/util/doors.mjs (lib never imports
// scripts); re-exported here under the same names for the callers of this module.
export { DOOR_LINE_RE, releaseSection, parseDoors } from '../lib/util/doors.mjs';
// The +/- lines of a unified diff, read inside its hunks only: a content line
// that starts with "++ " or "-- " looks like a file header but is not one.
@ -201,7 +161,7 @@ function reviewPath(root, review) {
return { path: null, error: `--review ${review} names two files: ${inRepo} (in --repo) and ${fromHere} (from here); pass an absolute path` };
}
function checkReview(reviewPath) {
function checkReview(reviewPath, repo) {
if (!reviewPath) return { check: { id: 'review', status: 'error', detail: 'no --review given — the release has no review verdict to read (K7)' }, text: null };
if (!existsSync(reviewPath)) return { check: { id: 'review', status: 'error', detail: `${reviewPath} does not exist — no review, no release` }, text: null };
let text;
@ -211,6 +171,14 @@ function checkReview(reviewPath) {
const v = decideVerdict(text);
if (v.exit === 2) return { check: { id: 'review', status: 'error', detail: `verdict gate could not decide: ${v.reason}` }, text: null };
if (v.exit === 1) return { check: { id: 'review', status: 'fail', detail: `review verdict BLOCK — ${v.reason}` }, text };
// Every counting MAJOR must be answered, read against the same top-level
// approval log the verdict gate reads (lib/review/verdict-gate.mjs
// `decideOpenFindings`): unanswered is not ready; any other exit 2 is undecided.
const open = decideOpenFindings(text, { repo, reviewPath });
if (open.exit === 2 && String(open.reason).startsWith('review-unanswered:')) {
return { check: { id: 'review', status: 'fail', detail: `review verdict ${v.verdict} with open findings — ${open.reason}` }, text };
}
if (open.exit !== 0) return { check: { id: 'review', status: 'error', detail: `the open-findings check could not decide: ${open.reason}` }, text: null };
return { check: { id: 'review', status: 'pass', detail: `review verdict ${v.verdict}` }, text };
}
@ -306,7 +274,7 @@ export function releaseCheck({ repo, review, version: explicitVersion, suiteTime
const where = reviewPath(root, review);
const { check: rev, text } = where.error
? { check: { id: 'review', status: 'error', detail: where.error }, text: null }
: checkReview(where.path);
: checkReview(where.path, root);
const coverage = checkCoverage(root, text);
const door = checkDoor(root, version);
const undecided = [tree, rev, coverage, door].filter((c) => c.status === 'error');