feat(queue): Piece D, reviews as issue comments, raw per-seat token helper (row 12, #1508)
queue move ID in-review posts the review request as a Gitea comment and review record reads verdicts back, so reviews stop being files in docs/plans/reviews/. On a comment round, in-review to waiting-on-jason now needs every listed reviewer's approval for the current round, the same as in-review to done (Filbert r1 C1). scripts/gitea-api.sh reads the raw per-seat token files (lead decisions 37 to 39): config built and checked before curl starts, export attribute cleared, fixed base URL. test-queue.sh skips its live checks outside the canonical root. Darkwing authored. Filbert approved D r2 (cf1d3fd0) after r1 (a2dc2302) and corrected the plan (293747cd). Rocko reviewed the helper (e896192f, 2096b0a3), and Sage's lead check passed under decision 38. Manifest b402fb38, 19 files. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
+326
-23
@@ -6,19 +6,29 @@ import { createHash } from "node:crypto";
|
||||
import { QueueError } from "./errors.mjs";
|
||||
|
||||
export const VERSION = 1;
|
||||
export const SEMANTICS = 1;
|
||||
// Semantics 2 is Piece D: a move into review on a row with reviewers opens
|
||||
// a request round, and the review-* verbs exist. Each entry replays under
|
||||
// the semantics it was written with.
|
||||
export const SEMANTICS = 2;
|
||||
export const STATES = ["queued", "briefed", "in-progress", "in-review", "waiting-on-jason", "done", "blocked", "parked"];
|
||||
const NON_TERMINAL = new Set(["queued", "briefed", "in-progress", "in-review", "waiting-on-jason"]);
|
||||
const CLAIM_KEPT = new Set(["in-progress", "in-review", "waiting-on-jason"]);
|
||||
export const PRIVILEGED = new Set(["jason", "sage"]);
|
||||
export const VERBS = ["genesis", "add", "move", "release", "assign", "note", "set", "accept-history"];
|
||||
export const REVIEW_VERBS = ["review-request", "review-outcome", "review-resolve", "review-abandon", "review-record"];
|
||||
export const VERBS = ["genesis", "add", "move", "release", "assign", "note", "set", "accept-history", ...REVIEW_VERBS];
|
||||
export const ATTEMPT_STATES = ["requesting", "posted", "failed", "uncertain", "abandoned", "conflict"];
|
||||
// Attempts that block a new request on their row (8.9).
|
||||
export const UNRESOLVED_STATES = ["requesting", "uncertain", "conflict"];
|
||||
const UNRESOLVED = new Set(UNRESOLVED_STATES);
|
||||
// The endpoint answered and refused: nothing was posted (8.9).
|
||||
export const FAILED_CODES = [400, 401, 403, 404, 422];
|
||||
export const SET_FIELDS = ["piece", "gate", "gate-owner", "after", "reviewers", "issues", "closes", "brief", "required"];
|
||||
|
||||
const NAME_RE = /^[a-z][a-z0-9-]{0,31}$/;
|
||||
export const CALLER_OP_RE = /^[a-z0-9][a-z0-9._-]{7,71}$/;
|
||||
// Room for `<op>.outcome`, which only an op id the CLI derives may use. No
|
||||
// verb derives one before Piece D, so replay applies CALLER_OP_RE to every
|
||||
// entry, genesis included.
|
||||
// Room for `<op>.outcome`, the op id of the entry that records a review
|
||||
// attempt's transport outcome (8.9). Only that verb uses it; every other
|
||||
// entry's op, genesis included, is a caller's op.
|
||||
export const LOG_OP_RE = /^[a-z0-9][a-z0-9._-]{7,79}$/;
|
||||
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
@@ -187,15 +197,109 @@ export function parseManifest(text) {
|
||||
return lines.length;
|
||||
}
|
||||
|
||||
// A checked manifest's lines as {digest, path}.
|
||||
export function manifestEntries(text) {
|
||||
parseManifest(text);
|
||||
return text.slice(0, -1).split("\n").map((line) => ({ digest: line.slice(0, 64), path: line.slice(66) }));
|
||||
}
|
||||
|
||||
// The request round an attempt belongs to, or null.
|
||||
export function attemptOf(row, op) {
|
||||
for (const r of row.review?.rounds ?? []) {
|
||||
if (r.request !== "comment") continue;
|
||||
const a = r.attempts.find((x) => x.op === op);
|
||||
if (a) return { round: r, attempt: a };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function checkCommentId(v, what = "comment id") {
|
||||
if (!Number.isSafeInteger(v) || v < 1) throw refuse(`${what} must be a positive integer: ${JSON.stringify(v)}`);
|
||||
return v;
|
||||
}
|
||||
|
||||
function checkDigest(v, what) {
|
||||
if (typeof v !== "string" || !(BLOB_RE.test(v) || SHA256_RE.test(v))) throw refuse(`${what} must be a 40-hex commit id or a 64-hex SHA-256`);
|
||||
return v;
|
||||
}
|
||||
|
||||
// What the transport reported for one attempt (8.9 step 3). `status` is
|
||||
// null when no HTTP status came back, which for `failed` means a pre-send
|
||||
// failure.
|
||||
export function checkTransport(t) {
|
||||
keysExactly(t, ["outcome", "status", "comment", "detail"], "transport outcome");
|
||||
if (!["posted", "failed", "uncertain"].includes(t.outcome)) throw refuse("transport outcome must be posted, failed or uncertain");
|
||||
if (t.status !== null && (!Number.isInteger(t.status) || t.status < 100 || t.status > 599)) throw refuse("transport status must be an HTTP status or null");
|
||||
if (t.comment !== null) checkCommentId(t.comment, "transport comment id");
|
||||
if ((t.outcome === "posted") !== (t.comment !== null) || (t.outcome === "posted" && t.status !== 201)) throw refuse("a transport outcome is posted exactly when HTTP 201 returned a comment id");
|
||||
if (t.outcome === "failed" && t.status !== null && !FAILED_CODES.includes(t.status)) throw refuse(`a failed outcome's status is one of ${FAILED_CODES.join(", ")}, or null before the POST`);
|
||||
checkText(t.detail, "transport detail", { max: 200 });
|
||||
return t;
|
||||
}
|
||||
|
||||
function checkAttempt(a) {
|
||||
keysExactly(a, ["op", "by", "at", "state", "comment", "transport", "resolutions"], "review attempt");
|
||||
checkCallerOpId(a.op, "review attempt op");
|
||||
checkName(a.by, "review attempt by");
|
||||
checkTime(a.at, "review attempt at");
|
||||
if (!ATTEMPT_STATES.includes(a.state)) throw refuse(`review attempt state ${JSON.stringify(a.state)} is not one of ${ATTEMPT_STATES.join(", ")}`);
|
||||
if (a.comment !== null) checkCommentId(a.comment);
|
||||
if ((a.state === "posted") !== (a.comment !== null)) throw refuse("a review attempt carries a comment id exactly when it is posted");
|
||||
if (a.transport !== null) checkTransport(a.transport);
|
||||
if (!Array.isArray(a.resolutions) || a.resolutions.length > 2) throw refuse("a review attempt holds at most two resolutions");
|
||||
for (const r of a.resolutions) {
|
||||
keysExactly(r, ["verb", "op", "by", "at", "comment", "reason"], "review resolution");
|
||||
checkCallerOpId(r.op, "review resolution op");
|
||||
checkName(r.by, "review resolution by");
|
||||
checkTime(r.at, "review resolution at");
|
||||
if (r.verb === "resolve") {
|
||||
checkCommentId(r.comment);
|
||||
if (r.reason !== null) throw refuse("a resolve carries no reason");
|
||||
} else if (r.verb === "abandon") {
|
||||
if (r.comment !== null) throw refuse("an abandon carries no comment id");
|
||||
checkText(r.reason, "abandon reason");
|
||||
} else {
|
||||
throw refuse("a review resolution is resolve or abandon");
|
||||
}
|
||||
}
|
||||
if (a.state === "requesting" && (a.transport !== null || a.resolutions.length > 0)) throw refuse("a requesting attempt has no outcome and no resolution");
|
||||
if (["failed", "uncertain", "conflict"].includes(a.state) && a.transport === null) throw refuse(`a ${a.state} attempt needs its transport outcome`);
|
||||
}
|
||||
|
||||
function checkReceipt(r) {
|
||||
keysExactly(r, ["reviewer", "op", "at", "verdict", "comment", "candidate"], "review receipt");
|
||||
checkName(r.reviewer, "review receipt reviewer");
|
||||
checkCallerOpId(r.op, "review receipt op");
|
||||
checkTime(r.at, "review receipt at");
|
||||
if (r.verdict !== "approve" && r.verdict !== "changes") throw refuse("a verdict is approve or changes");
|
||||
checkCommentId(r.comment, "review receipt comment id");
|
||||
checkDigest(r.candidate, "review receipt candidate");
|
||||
}
|
||||
|
||||
const ROUND_KEYS = ["n", "op", "by", "at", "issue", "candidate", "request"];
|
||||
|
||||
// A round is `request: "none"` (opened before Piece D, or on a row with no
|
||||
// reviewers) or `request: "comment"`: the move posts a request comment, and
|
||||
// the round keeps its attempts and the reviewers' receipts (8.9).
|
||||
function checkRound(v, n) {
|
||||
keysExactly(v, ["n", "op", "by", "at", "issue", "candidate", "request"], "review round");
|
||||
keysExactly(v, isObj(v) && v.request === "comment" ? [...ROUND_KEYS, "attempts", "duplicateRisk", "receipts"] : ROUND_KEYS, "review round");
|
||||
if (v.n !== n) throw refuse(`review rounds must be numbered from 1; expected ${n}`);
|
||||
checkCallerOpId(v.op, "review round op");
|
||||
checkName(v.by, "review round by");
|
||||
checkTime(v.at, "review round at");
|
||||
checkId(v.issue, "review round issue");
|
||||
checkCandidate(v.candidate);
|
||||
if (v.request !== "none") throw refuse("review round request must be none before Piece D");
|
||||
if (v.request === "none") return;
|
||||
if (v.request !== "comment") throw refuse("review round request must be none or comment");
|
||||
if (!Array.isArray(v.attempts) || v.attempts.length === 0) throw refuse("a request round needs at least one attempt");
|
||||
v.attempts.forEach(checkAttempt);
|
||||
if (v.attempts[0].op !== v.op) throw refuse("a request round's first attempt is the move that opened it");
|
||||
if (new Set(v.attempts.map((a) => a.op)).size !== v.attempts.length) throw refuse("a request round names an attempt twice");
|
||||
const abandoned = v.attempts.some((a) => a.resolutions.some((r) => r.verb === "abandon"));
|
||||
if (v.duplicateRisk !== abandoned) throw refuse("a request round's duplicateRisk is true exactly when an attempt was abandoned");
|
||||
if (!Array.isArray(v.receipts)) throw refuse("a request round's receipts must be a list");
|
||||
v.receipts.forEach(checkReceipt);
|
||||
if (new Set(v.receipts.map((r) => r.reviewer)).size !== v.receipts.length) throw refuse("a reviewer has one receipt per round");
|
||||
}
|
||||
|
||||
export function validateRow(row) {
|
||||
@@ -377,6 +481,19 @@ export function canonArgs(verb, a) {
|
||||
}
|
||||
case "accept-history":
|
||||
return { reason: checkText(a.reason, "reason") };
|
||||
case "review-request":
|
||||
return { id: checkId(a.id) };
|
||||
case "review-outcome": {
|
||||
const t = checkTransport({ outcome: a.outcome, status: a.status, comment: a.comment, detail: a.detail });
|
||||
return { id: checkId(a.id), attempt: checkCallerOpId(a.attempt, "attempt"), ...t };
|
||||
}
|
||||
case "review-resolve":
|
||||
return { id: checkId(a.id), attempt: checkCallerOpId(a.attempt, "attempt"), comment: checkCommentId(a.comment) };
|
||||
case "review-abandon":
|
||||
return { id: checkId(a.id), attempt: checkCallerOpId(a.attempt, "attempt"), reason: checkText(a.reason, "reason") };
|
||||
case "review-record":
|
||||
if (a.verdict !== "approve" && a.verdict !== "changes") throw refuse("--verdict must be approve or changes");
|
||||
return { id: checkId(a.id), verdict: a.verdict, comment: checkCommentId(a.comment), candidate: checkDigest(a.candidate, "candidate") };
|
||||
default:
|
||||
throw refuse(`unknown verb ${JSON.stringify(verb)}`);
|
||||
}
|
||||
@@ -392,8 +509,8 @@ const isPriv = (by) => PRIVILEGED.has(by);
|
||||
|
||||
function ownerOrPriv(row, by, doing) {
|
||||
if (by === row.owner || isPriv(by)) return;
|
||||
if (row.claim !== null) throw refuse(`row ${row.id} is claimed by ${row.claim.seat}; ${by} cannot ${doing}`);
|
||||
throw refuse(`only the owner (${row.owner}) or a privileged actor may ${doing} row ${row.id}`);
|
||||
const claimed = row.claim !== null ? `row ${row.id} is claimed by ${row.claim.seat}; ` : "";
|
||||
throw refuse(`${claimed}only the owner (${row.owner}) or a privileged actor may ${doing} row ${row.id}`);
|
||||
}
|
||||
|
||||
function requirePriv(by, doing) {
|
||||
@@ -442,6 +559,146 @@ function reviewIssue(row, issue) {
|
||||
return row.issues[0];
|
||||
}
|
||||
|
||||
// Attempts still waiting on an outcome, a resolve or an abandon. A new
|
||||
// request on the row, and closing it, wait for them (8.9).
|
||||
function unresolvedAttempts(row) {
|
||||
const out = [];
|
||||
for (const r of row.review?.rounds ?? []) {
|
||||
if (r.request !== "comment") continue;
|
||||
for (const a of r.attempts) if (UNRESOLVED.has(a.state)) out.push(`${a.op} (round ${r.n}, ${a.state})`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function refuseUnresolved(row, doing) {
|
||||
const open = unresolvedAttempts(row);
|
||||
if (open.length) throw refuse(`row ${row.id} has an unresolved review request: ${open.join(", ")}; resolve or abandon it before ${doing}`);
|
||||
}
|
||||
|
||||
// A comment round leaves in-review, for done or for Jason's gate, only on
|
||||
// an approval recorded by every listed reviewer (8.9).
|
||||
function requireApprovals(row, cur, to) {
|
||||
if (row.reviewers.length === 0) throw refuse(`row ${row.id} lists no reviewers now; a privileged actor sets them before it ${to === "done" ? "closes" : `moves to ${to}`}`);
|
||||
const approved = new Set(cur.receipts.filter((r) => r.verdict === "approve").map((r) => r.reviewer));
|
||||
const missing = row.reviewers.filter((s) => !approved.has(s));
|
||||
if (missing.length) throw refuse(`row ${row.id} round ${cur.n} has no approval recorded by ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
// The request round an attempt belongs to, and the attempt.
|
||||
function findAttempt(row, op) {
|
||||
for (const r of row.review?.rounds ?? []) {
|
||||
if (r.request !== "comment") continue;
|
||||
const i = r.attempts.findIndex((x) => x.op === op);
|
||||
if (i >= 0) return { round: r, index: i, attempt: r.attempts[i] };
|
||||
}
|
||||
throw refuse(`row ${row.id} has no review request ${op}`);
|
||||
}
|
||||
|
||||
// A copy of `row` with one attempt of one round replaced.
|
||||
function withAttempt(row, found, attempt, roundExtra = {}) {
|
||||
const rounds = row.review.rounds.map((r) => {
|
||||
if (r.n !== found.round.n) return r;
|
||||
return { ...r, ...roundExtra, attempts: r.attempts.map((x, i) => (i === found.index ? attempt : x)) };
|
||||
});
|
||||
return { ...row, review: { rounds } };
|
||||
}
|
||||
|
||||
// The current round, which review-request and review-record act on.
|
||||
function currentCommentRound(row, doing) {
|
||||
if (row.state !== "in-review") throw refuse(`row ${row.id} is ${row.state}; ${doing} applies to in-review rows`);
|
||||
const cur = row.review.rounds.at(-1);
|
||||
if (cur.request !== "comment") throw refuse(`row ${row.id} round ${cur.n} posts no request (the row had no reviewers when it opened); ${doing} does not apply`);
|
||||
return cur;
|
||||
}
|
||||
|
||||
function newAttempt(entry) {
|
||||
return { op: entry.op, by: entry.by, at: entry.at, state: "requesting", comment: null, transport: null, resolutions: [] };
|
||||
}
|
||||
|
||||
// review-outcome: what the transport reported, against the attempt's state
|
||||
// by then (8.9 step 3). A resolve or abandon may have come first.
|
||||
function outcomeState(attempt, t) {
|
||||
if (attempt.state === "requesting") return t.outcome;
|
||||
if (attempt.state === "posted") {
|
||||
if (t.outcome === "uncertain") return "posted";
|
||||
return t.outcome === "posted" && t.comment === attempt.comment ? "posted" : "conflict";
|
||||
}
|
||||
if (attempt.state === "abandoned") return t.outcome === "posted" ? "conflict" : "abandoned";
|
||||
throw refuse(`attempt ${attempt.op} is ${attempt.state}; it already has its outcome`);
|
||||
}
|
||||
|
||||
function applyReview(rows, row, entry) {
|
||||
const a = entry.args;
|
||||
const by = entry.by;
|
||||
if (!row.review && entry.verb !== "review-request" && entry.verb !== "review-record") throw refuse(`row ${row.id} has no review request ${a.attempt}`);
|
||||
switch (entry.verb) {
|
||||
case "review-request": {
|
||||
ownerOrPriv(row, by, "request review of");
|
||||
const cur = currentCommentRound(row, "review request");
|
||||
const live = cur.attempts.filter((x) => x.state !== "failed" && x.state !== "abandoned");
|
||||
refuseUnresolved(row, "a new request");
|
||||
if (live.length) throw refuse(`row ${row.id} round ${cur.n} already has a posted request (${live[0].op}, comment ${live[0].comment})`);
|
||||
const rounds = row.review.rounds.map((r) => (r.n === cur.n ? { ...r, attempts: [...r.attempts, newAttempt(entry)] } : r));
|
||||
return {
|
||||
row: { ...row, review: { rounds } },
|
||||
result: { row: row.id, round: cur.n, attempt: entry.op, state: "requesting" },
|
||||
text: `row ${row.id} round ${cur.n} request ${entry.op} requesting`,
|
||||
};
|
||||
}
|
||||
case "review-outcome": {
|
||||
if (entry.op !== `${a.attempt}.outcome`) throw refuse(`an outcome's op is its attempt's op plus .outcome (${a.attempt}.outcome)`);
|
||||
const found = findAttempt(row, a.attempt);
|
||||
const att = found.attempt;
|
||||
if (by !== att.by) throw refuse(`only ${att.by}, who made request ${att.op}, records its outcome`);
|
||||
if (att.transport !== null) throw refuse(`request ${att.op} already has its outcome`);
|
||||
const t = { outcome: a.outcome, status: a.status, comment: a.comment, detail: a.detail };
|
||||
const state = outcomeState(att, t);
|
||||
const comment = state === "posted" ? (att.comment ?? t.comment) : null;
|
||||
const next = withAttempt(row, found, { ...att, state, comment, transport: t });
|
||||
const shown = state === t.outcome ? state : `${state} (transport ${t.outcome})`;
|
||||
return {
|
||||
row: next,
|
||||
result: { row: row.id, round: found.round.n, attempt: att.op, state },
|
||||
text: `row ${row.id} round ${found.round.n} request ${att.op} ${shown}${comment !== null ? ` comment ${comment}` : ""}`,
|
||||
};
|
||||
}
|
||||
case "review-resolve":
|
||||
case "review-abandon": {
|
||||
const resolve = entry.verb === "review-resolve";
|
||||
if (row.state === "done") throw refuse(`row ${row.id} is done; done rows never change`);
|
||||
if (resolve) ownerOrPriv(row, by, "resolve a request on");
|
||||
else requirePriv(by, "abandon a review request");
|
||||
const found = findAttempt(row, a.attempt);
|
||||
const att = found.attempt;
|
||||
if (!UNRESOLVED.has(att.state)) throw refuse(`request ${att.op} is ${att.state}; only a requesting, uncertain or conflict request can be ${resolve ? "resolved" : "abandoned"}`);
|
||||
const res = { verb: resolve ? "resolve" : "abandon", op: entry.op, by, at: entry.at, comment: resolve ? a.comment : null, reason: resolve ? null : a.reason };
|
||||
const state = resolve ? "posted" : "abandoned";
|
||||
const next = withAttempt(row, found, { ...att, state, comment: resolve ? a.comment : null, resolutions: [...att.resolutions, res] }, resolve ? {} : { duplicateRisk: true });
|
||||
return {
|
||||
row: next,
|
||||
result: { row: row.id, round: found.round.n, attempt: att.op, state },
|
||||
text: `row ${row.id} round ${found.round.n} request ${att.op} ${att.state}→${state}${resolve ? ` comment ${a.comment}` : "; a duplicate request comment may exist"}`,
|
||||
};
|
||||
}
|
||||
case "review-record": {
|
||||
if (!row.reviewers.includes(by) || by === row.owner) throw refuse(`only a listed reviewer other than the owner may record a verdict on row ${row.id} (reviewers: ${fmt(row.reviewers)})`);
|
||||
if (!row.review) throw refuse(`row ${row.id} has no review round`);
|
||||
const cur = currentCommentRound(row, "review record");
|
||||
if (a.candidate !== cur.candidate.digest) throw refuse(`candidate ${a.candidate} is not round ${cur.n}'s candidate ${cur.candidate.digest}`);
|
||||
if (cur.receipts.some((r) => r.reviewer === by)) throw refuse(`${by} already recorded a verdict for row ${row.id} round ${cur.n}`);
|
||||
const rec = { reviewer: by, op: entry.op, at: entry.at, verdict: a.verdict, comment: a.comment, candidate: a.candidate };
|
||||
const rounds = row.review.rounds.map((r) => (r.n === cur.n ? { ...r, receipts: [...r.receipts, rec] } : r));
|
||||
return {
|
||||
row: { ...row, review: { rounds } },
|
||||
result: { row: row.id, round: cur.n, verdict: a.verdict },
|
||||
text: `row ${row.id} round ${cur.n} ${a.verdict} by ${by} (comment ${a.comment})`,
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw refuse(`unknown verb ${entry.verb}`);
|
||||
}
|
||||
}
|
||||
|
||||
function touch(row, entry) {
|
||||
return { ...row, updatedAt: entry.at, updatedBy: entry.by };
|
||||
}
|
||||
@@ -469,6 +726,8 @@ function applyMove(rows, row, entry, resolved) {
|
||||
const { to, reason, candidate, evidence, issue } = entry.args;
|
||||
const by = entry.by;
|
||||
const from = row.state;
|
||||
const v2 = entry.semantics >= 2;
|
||||
let request = null;
|
||||
const illegal = () => refuse(`row ${row.id}: ${from}→${to} is not a transition`);
|
||||
if (from === "done") throw refuse(`row ${row.id} is done; done rows never change`);
|
||||
if (reason !== null && to !== "blocked") throw refuse("--reason applies only to a move to blocked");
|
||||
@@ -505,12 +764,23 @@ function applyMove(rows, row, entry, resolved) {
|
||||
cand = checkCandidate(resolved.candidate);
|
||||
const rounds = row.review ? row.review.rounds : [];
|
||||
round = rounds.length + 1;
|
||||
next.review = {
|
||||
rounds: [...rounds, { n: round, op: entry.op, by, at: entry.at, issue: revIssue, candidate: cand, request: "none" }],
|
||||
};
|
||||
} else if (from === "in-review" && (to === "in-progress" || to === "waiting-on-jason")) {
|
||||
let opened = { n: round, op: entry.op, by, at: entry.at, issue: revIssue, candidate: cand, request: "none" };
|
||||
// Semantics 2: a row with reviewers asks them in a comment (8.9).
|
||||
if (v2 && row.reviewers.length > 0) {
|
||||
refuseUnresolved(row, "a new round");
|
||||
opened = { ...opened, request: "comment", attempts: [newAttempt(entry)], duplicateRisk: false, receipts: [] };
|
||||
request = `; request ${entry.op} requesting`;
|
||||
}
|
||||
next.review = { rounds: [...rounds, opened] };
|
||||
} else if (from === "in-review" && to === "in-progress") {
|
||||
ownerOrPriv(row, by, `move to ${to}`);
|
||||
} else if (from === "in-review" && to === "waiting-on-jason") {
|
||||
ownerOrPriv(row, by, `move to ${to}`);
|
||||
refuseUnresolved(row, "moving it to waiting-on-jason");
|
||||
const cur = row.review?.rounds.at(-1);
|
||||
if (cur?.request === "comment") requireApprovals(row, cur, to);
|
||||
} else if (from === "waiting-on-jason" && to === "done") {
|
||||
refuseUnresolved(row, "closing it");
|
||||
if (by === "sage") {
|
||||
if (evidence === null) throw refuse("sage closes a waiting-on-jason row only with --evidence citing Jason's approval");
|
||||
} else {
|
||||
@@ -520,11 +790,18 @@ function applyMove(rows, row, entry, resolved) {
|
||||
} else if (from === "in-review" && to === "done") {
|
||||
if (row.gateOwner === "jason") throw refuse(`row ${row.id}'s gate is Jason's; it goes through waiting-on-jason`);
|
||||
if (by !== row.gateOwner && !isPriv(by)) throw refuse(`only the gate owner (${row.gateOwner}) or a privileged actor may close row ${row.id}`);
|
||||
const ev = parseReviewEvidence(evidence);
|
||||
refuseUnresolved(row, "closing it");
|
||||
const cur = row.review?.rounds.at(-1);
|
||||
if (!cur) throw refuse(`row ${row.id} has no review round to cite`);
|
||||
if (ev.round !== cur.n) throw refuse(`evidence names round ${ev.round}; row ${row.id} is in round ${cur.n}`);
|
||||
if (ev.candidate !== cur.candidate.digest) throw refuse(`evidence candidate ${ev.candidate} is not round ${cur.n}'s candidate ${cur.candidate.digest}`);
|
||||
if (cur?.request === "comment") {
|
||||
// The reviewers' receipts are the evidence (8.9).
|
||||
if (evidence !== null) throw refuse(`row ${row.id} round ${cur.n} closes on its recorded verdicts; drop --evidence`);
|
||||
requireApprovals(row, cur, to);
|
||||
} else {
|
||||
const ev = parseReviewEvidence(evidence);
|
||||
if (!cur) throw refuse(`row ${row.id} has no review round to cite`);
|
||||
if (ev.round !== cur.n) throw refuse(`evidence names round ${ev.round}; row ${row.id} is in round ${cur.n}`);
|
||||
if (ev.candidate !== cur.candidate.digest) throw refuse(`evidence candidate ${ev.candidate} is not round ${cur.n}'s candidate ${cur.candidate.digest}`);
|
||||
}
|
||||
round = cur.n;
|
||||
next.claim = null;
|
||||
} else if ((from === "queued" || from === "briefed") && to === "parked") {
|
||||
@@ -536,7 +813,7 @@ function applyMove(rows, row, entry, resolved) {
|
||||
throw illegal();
|
||||
}
|
||||
next = touch(next, entry);
|
||||
return { row: next, result: { row: row.id, from, to, round, issue: revIssue, candidate: cand } };
|
||||
return { row: next, result: { row: row.id, from, to, round, issue: revIssue, candidate: cand }, request };
|
||||
}
|
||||
|
||||
function applySet(rows, row, entry, resolved) {
|
||||
@@ -637,7 +914,22 @@ export function applyEntry(state, entry, resolved) {
|
||||
const out = applyMove(rows, row, entry, resolved);
|
||||
rows.set(row.id, out.row);
|
||||
const r = out.result;
|
||||
result = { ...r, receipt: receipt(entry, rev, `row ${row.id} ${r.from}→${r.to}${r.round ? ` round ${r.round}` : ""}${r.issue ? ` on #${r.issue}` : ""}`) };
|
||||
result = { ...r, receipt: receipt(entry, rev, `row ${row.id} ${r.from}→${r.to}${r.round ? ` round ${r.round}` : ""}${r.issue ? ` on #${r.issue}` : ""}${out.request ?? ""}`) };
|
||||
break;
|
||||
}
|
||||
case "review-request":
|
||||
case "review-outcome":
|
||||
case "review-resolve":
|
||||
case "review-abandon":
|
||||
case "review-record": {
|
||||
if (entry.semantics < 2) throw refuse(`${entry.verb} needs semantics 2`);
|
||||
const row = getRow(rows, a.id);
|
||||
// review-outcome is the one entry a done row takes: a transport can
|
||||
// answer after the row closed (8.9).
|
||||
if (row.state === "done" && entry.verb !== "review-outcome") throw refuse(`row ${row.id} is done; done rows never change`);
|
||||
const out = applyReview(rows, row, entry);
|
||||
rows.set(row.id, touch(out.row, entry));
|
||||
result = { ...out.result, receipt: receipt(entry, rev, out.text) };
|
||||
break;
|
||||
}
|
||||
case "release": {
|
||||
@@ -795,11 +1087,16 @@ export function rowsArray(state) {
|
||||
function checkEntryShape(e, i) {
|
||||
keysExactly(e, ENTRY_KEYS, `log entry ${i}`);
|
||||
if (e.rev !== i) throw refuse(`log entry ${i} has rev ${e.rev}`);
|
||||
checkCallerOpId(e.op, `log entry ${i} op`);
|
||||
if (!VERBS.includes(e.verb)) throw refuse(`log entry ${i} verb ${JSON.stringify(e.verb)} is unknown`);
|
||||
if (e.verb === "review-outcome") {
|
||||
if (typeof e.op !== "string" || !LOG_OP_RE.test(e.op) || !isObj(e.args) || e.op !== `${e.args.attempt}.outcome`) throw refuse(`log entry ${i} op must be its attempt's op plus .outcome`);
|
||||
} else {
|
||||
checkCallerOpId(e.op, `log entry ${i} op`);
|
||||
}
|
||||
checkName(e.by, `log entry ${i} by`);
|
||||
checkTime(e.at, `log entry ${i} at`);
|
||||
if (e.semantics !== SEMANTICS) throw refuse(`log entry ${i} semantics ${e.semantics} is not ${SEMANTICS}`);
|
||||
if (!Number.isInteger(e.semantics) || e.semantics < 1 || e.semantics > SEMANTICS) throw refuse(`log entry ${i} semantics ${e.semantics} is not 1 to ${SEMANTICS}`);
|
||||
if (REVIEW_VERBS.includes(e.verb) && e.semantics < 2) throw refuse(`log entry ${i} ${e.verb} needs semantics 2`);
|
||||
if (typeof e.viewSha !== "string" || !SHA256_RE.test(e.viewSha)) throw refuse(`log entry ${i} viewSha is not a SHA-256`);
|
||||
}
|
||||
|
||||
@@ -867,7 +1164,9 @@ export function nextFor(rows, seat, briefMatches) {
|
||||
const byId = new Map(list.map((r) => [r.id, r]));
|
||||
const resume = list.find((r) => r.state === "in-progress" && r.claim?.seat === seat);
|
||||
if (resume) return { action: "resume", row: resume };
|
||||
const review = list.find((r) => r.state === "in-review" && r.reviewers.includes(seat) && r.owner !== seat);
|
||||
// A reviewer who recorded a verdict on the current round is done with it.
|
||||
const recorded = (r) => r.review.rounds.at(-1).receipts?.some((x) => x.reviewer === seat) ?? false;
|
||||
const review = list.find((r) => r.state === "in-review" && r.reviewers.includes(seat) && r.owner !== seat && !(r.review && recorded(r)));
|
||||
if (review) return { action: "review", row: review };
|
||||
const start = list.find((r) => r.state === "briefed" && r.owner === seat && afterSatisfied(byId, r).length === 0);
|
||||
if (start) return { action: "start", row: start, briefDiffers: !briefMatches(start) };
|
||||
@@ -885,7 +1184,11 @@ function cell(text) {
|
||||
function stateCell(r) {
|
||||
let s = r.state;
|
||||
if (r.state === "blocked") s = `blocked (from ${r.previousState}): ${r.blockedReason}`;
|
||||
else if (r.state === "in-review" && r.review) s = `in-review, round ${r.review.rounds.length}`;
|
||||
else if (r.state === "in-review" && r.review) {
|
||||
const cur = r.review.rounds.at(-1);
|
||||
s = `in-review, round ${cur.n}`;
|
||||
if (cur.request === "comment") s += `, request ${cur.attempts.at(-1).state}`;
|
||||
}
|
||||
return r.required ? `required; ${s}` : s;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user