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:
+86
-10
@@ -33,6 +33,8 @@ scripts/test-queue.sh
|
||||
| `sync [--op ID]` | yes | fsyncs and the witness; logs nothing |
|
||||
| `snapshot --out DIR` | yes | copies of both files into an empty DIR outside the repository |
|
||||
| `unlock [--check-gate]` | no; takes the unlock gate | removes a dead or mismatched lock |
|
||||
| `review request`, `review resolve`, `review abandon`, `review record` | yes | one log entry, the witness, the table; a request also posts one comment and logs its outcome |
|
||||
| `review verify-commit ID REF` | no | nothing; reads the queue and git objects |
|
||||
|
||||
Every change takes `--op ID` (8 to 72 characters of `[a-z0-9._-]`,
|
||||
starting with a letter or digit, not ending in `.outcome`) and an actor from `--by NAME` or
|
||||
@@ -52,12 +54,13 @@ or genesis from the map), it keeps the part still among the new issues, and
|
||||
the receipt ends in `(kept narrowed)`.
|
||||
|
||||
Exit codes: 0 ok; 1 the operation failed; 2 invalid data or refused;
|
||||
3 uncertain (visible or durable, not acknowledged); 4 usage.
|
||||
3 uncertain (visible or durable, not acknowledged; for a review request,
|
||||
not known to be posted); 4 usage.
|
||||
|
||||
Until piece D, `move ID in-review` needs `--candidate`: an existing file is
|
||||
read as a manifest (one `<sha256> <path>` line per file), anything else as a
|
||||
commit reachable from `refs/heads` or `refs/tags`. The candidate is frozen
|
||||
for the round.
|
||||
`move ID in-review` needs `--candidate`. An existing file is read as a
|
||||
manifest (one `<sha256> <path>` line per file), anything else as a commit
|
||||
reachable from `refs/heads` or `refs/tags`. The candidate is frozen for the
|
||||
round.
|
||||
|
||||
The review's issue follows lead decision 23. A row with no issues can't
|
||||
request review. A row with one issue uses it. A row with several needs
|
||||
@@ -66,10 +69,72 @@ unless `--issue` names another; if the row no longer lists the kept issue,
|
||||
the request refuses until `--issue` names one. Each round records the issue
|
||||
it used (`review.rounds[].issue`).
|
||||
|
||||
`move ID done` from in-review needs `--evidence
|
||||
comment=<id>,round=<n>,candidate=<digest>`. The round must be the current
|
||||
one and the digest its candidate's, so a comment from an earlier round
|
||||
can't close a later one, even when the candidate is the same.
|
||||
A row with reviewers opens a request round and posts the review request
|
||||
comment; see "Review requests". A row with no reviewers opens a round that
|
||||
posts nothing. For that kind of round, `move ID done` from in-review needs
|
||||
`--evidence comment=<id>,round=<n>,candidate=<digest>`. The round must be
|
||||
the current one and the digest its candidate's, so a comment from an
|
||||
earlier round can't close a later one, even when the candidate is the same.
|
||||
|
||||
## Review requests
|
||||
|
||||
Piece D, section 8.9 of the plan. `move ID in-review` from in-progress, on
|
||||
a row with reviewers, logs the new round and a request attempt in the `requesting`
|
||||
state, then posts one comment on the round's issue as the acting seat and
|
||||
logs what the transport said as a second entry, `<op>.outcome`. `review
|
||||
request ID` makes a new attempt in the current round after a failed or
|
||||
abandoned one.
|
||||
|
||||
- **Credentials.** The request posts with the acting seat's own token file,
|
||||
named by `MOSAIC_GITEA_CREDENTIAL_FILE`. It must end in
|
||||
`/agents/<login>/secrets/gitea-mosaicstack-<login>.token`, be a regular
|
||||
file (no symlink, and its real path ends the same way), belong to this
|
||||
user and be mode 0600. The lead's login is `jarvis`. The shared
|
||||
`~/secrets/mosaic.gitea.json` refuses. The queue checks the file with
|
||||
`lstat` and never opens it; `scripts/gitea-api.sh` reads it. Before the
|
||||
POST, `GET user` must name the seat's login.
|
||||
- **Outcome.** HTTP 201 with a comment id is `posted` (exit 0). HTTP 400,
|
||||
401, 403, 404 or 422, or a failed pre-send check, is `failed` (exit 1).
|
||||
Anything else is `uncertain` (exit 3): another status, 201 without an id,
|
||||
a transport error, or no answer before the 30-second deadline, which kills
|
||||
the helper and curl with it. The log records the status and a fixed
|
||||
detail, never the response body.
|
||||
- **Retries never post.** Running the same `--op` again prints what the log
|
||||
holds and sends nothing. A request left `requesting` (the process died
|
||||
after the log entry) or `uncertain` needs a person to look on the issue
|
||||
for a comment carrying `<!-- mosaic-queue-op: OP -->`. If it is there,
|
||||
the owner or a privileged actor runs `review resolve ID OP --comment N`.
|
||||
The queue fetches that comment and refuses unless it is on the round's
|
||||
issue, written by the requester's login, and carries both markers for
|
||||
this attempt, round and candidate. If it is not there, a privileged actor
|
||||
runs `review abandon ID OP --reason TEXT --yes`. An abandon sets the
|
||||
round's `duplicateRisk`, because the comment may still land.
|
||||
- **Late outcomes.** An outcome that arrives after a resolve with the same
|
||||
comment id keeps `posted`. One that disagrees, or a posted outcome after
|
||||
an abandon, makes the attempt `conflict`, which a resolve settles. The
|
||||
outcome entry is the one entry a done row still accepts, so a late answer
|
||||
is recorded even after the row closed.
|
||||
- **Unresolved requests** (`requesting`, `uncertain`, `conflict`) block a
|
||||
new request, a new round, a move to `waiting-on-jason` and `done`.
|
||||
- **Verdicts.** Each listed reviewer other than the owner posts a comment
|
||||
on the issue, then runs `review record ID --verdict approve|changes
|
||||
--comment N --candidate DIGEST`, once per round. DIGEST must be the
|
||||
round's candidate. On a request round, `move ID done` and
|
||||
`move ID waiting-on-jason` each need an approval recorded by every
|
||||
reviewer in the current round, so a Jason-gated row reaches Jason only
|
||||
after its reviewers approved. `done` also refuses `--evidence`. The queue
|
||||
doesn't fetch the verdict comment; `record` is cooperative like every
|
||||
other actor claim.
|
||||
- **`review verify-commit ID REF`** checks a prospective commit or tree
|
||||
against the current round's candidate. For a manifest, every listed path
|
||||
must hash to its digest. For a commit candidate, every path the candidate
|
||||
changed from its first parent must match in REF by blob and mode, and
|
||||
every path it deleted must be absent.
|
||||
|
||||
Each log entry records the `semantics` it was written under. Entries from
|
||||
semantics 1, before piece D, replay under the old rules: a move to
|
||||
in-review opens a round that posts nothing. Review entries need
|
||||
semantics 2.
|
||||
|
||||
## Where the files live
|
||||
|
||||
@@ -223,6 +288,12 @@ reporting, so a write in progress is never reported as lost history.
|
||||
witness continuity. Canonical `verify`, under the lock, does that.
|
||||
- **Commit candidates** stay retrievable only while some ref keeps the
|
||||
commit. The queue keeps the manifest text, not the source bytes.
|
||||
`review verify-commit` on a commit candidate needs that commit.
|
||||
- **Post to outcome.** A kill after the POST and before the outcome entry
|
||||
leaves the attempt `requesting` with a comment on the issue. The queue
|
||||
never posts on its own again; `review resolve` records the comment.
|
||||
- **Verdict comments** are not fetched. `review record` trusts the
|
||||
reviewer's comment id the way the queue trusts `--by`.
|
||||
- **Locks** are never removed for their age. `unknown` and `invalid` locks
|
||||
wait for a person. A host rename makes old locks `unknown`.
|
||||
- `queue unlock --check-gate` classifies a stale unlock gate. Remove the
|
||||
@@ -264,7 +335,7 @@ then `queue render`.
|
||||
`node --test packages/queue/tests/` runs everything in scratch repositories
|
||||
under the system temp directory; nothing touches this checkout's `.git`.
|
||||
Faults reach the code only through options the tests pass in (`io`, `proc`,
|
||||
`hook`, `now`, `readOrder`, `lockWaitMs`); the CLI passes none.
|
||||
`hook`, `now`, `readOrder`, `lockWaitMs`, `deadlineMs`); the CLI passes none.
|
||||
|
||||
- `data.test.mjs`: serialization, replay, the transition matrix, `next`
|
||||
ordering, render.
|
||||
@@ -280,3 +351,8 @@ Faults reach the code only through options the tests pass in (`io`, `proc`,
|
||||
`map-check.mjs` reports each kind of drift.
|
||||
- `commit.test.mjs`: `queue-commit.sh` and the guard, through PATH shims
|
||||
that run an action at an exact point in the procedure.
|
||||
- `review.test.mjs`: review requests against `fixtures/fake-gitea.mjs`,
|
||||
installed as the scratch repository's `scripts/gitea-api.sh`, with dummy
|
||||
token files. Every transport answer, the deadline, kills at each step,
|
||||
a held lock at the outcome, late outcomes, resolve checks, credential
|
||||
checks, verdicts across rounds, `verify-commit` and semantics 1 replay.
|
||||
|
||||
@@ -8,17 +8,22 @@
|
||||
// release ID | assign ID SEAT | note ID TEXT | set ID FIELD VALUE [--reason TEXT]
|
||||
// genesis --root PATH --branch NAME --map PATH
|
||||
// accept-history --reason TEXT --yes
|
||||
// review request ID | review resolve ID ATTEMPT --comment N
|
||||
// review abandon ID ATTEMPT --reason TEXT --yes
|
||||
// review record ID --verdict approve|changes --comment N --candidate DIGEST
|
||||
// Each needs --op ID and an actor (--by NAME or $MOSAIC_AGENT_NAME).
|
||||
// Read only: review verify-commit ID REF
|
||||
// No log: render [--check] | verify [--current] | verify --snapshot DIR (--base-file F | --base-absent)
|
||||
// sync [--op ID] | snapshot --out DIR | unlock [--check-gate]
|
||||
//
|
||||
// Exit codes: 0 ok; 1 operation failed; 2 invalid data or refused;
|
||||
// 3 uncertain (visible or durable, not acknowledged); 4 usage.
|
||||
// 3 uncertain (visible or durable, not acknowledged; for a request, not
|
||||
// known to be posted); 4 usage.
|
||||
import { realpathSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { QueueError } from "./errors.mjs";
|
||||
import { SET_FIELDS } from "./queue.mjs";
|
||||
import { list, mutate, next, renderView, show, snapshot, sync, unlock, verify, verifySnapshot } from "./store.mjs";
|
||||
import { list, mutate, next, renderView, show, snapshot, sync, unlock, verify, verifyCommit, verifySnapshot } from "./store.mjs";
|
||||
|
||||
const USAGE = [
|
||||
"usage: queue list | show ID | next [SEAT]",
|
||||
@@ -28,6 +33,10 @@ const USAGE = [
|
||||
` queue set ID FIELD VALUE --op ID [--reason TEXT] (fields: ${SET_FIELDS.join(", ")})`,
|
||||
" queue genesis --op ID --root PATH --branch NAME --map PATH",
|
||||
" queue accept-history --op ID --reason TEXT --yes",
|
||||
" queue review request ID --op ID | review resolve ID ATTEMPT --comment N --op ID",
|
||||
" queue review abandon ID ATTEMPT --reason TEXT --yes --op ID",
|
||||
" queue review record ID --verdict approve|changes --comment N --candidate DIGEST --op ID",
|
||||
" queue review verify-commit ID REF",
|
||||
" queue render [--check] | verify [--current] | verify --snapshot DIR (--base-file F | --base-absent)",
|
||||
" queue sync [--op ID] | snapshot --out DIR | unlock [--check-gate]",
|
||||
" every change takes --by NAME, else $MOSAIC_AGENT_NAME",
|
||||
@@ -36,6 +45,7 @@ const USAGE = [
|
||||
const VALUE_FLAGS = new Set([
|
||||
"--op", "--by", "--piece", "--gate", "--brief", "--issue", "--note", "--owner", "--gate-owner", "--after", "--reviewer",
|
||||
"--reason", "--candidate", "--evidence", "--root", "--branch", "--map", "--snapshot", "--base-file", "--out",
|
||||
"--verdict", "--comment",
|
||||
]);
|
||||
const REPEATED = new Set(["--issue", "--after", "--reviewer"]);
|
||||
const BOOL_FLAGS = new Set(["--required", "--yes", "--check", "--current", "--base-absent", "--check-gate"]);
|
||||
@@ -80,6 +90,38 @@ function intArg(v, what) {
|
||||
return Number(v);
|
||||
}
|
||||
|
||||
// Gitea comment ids outgrow a row id's seven digits.
|
||||
function commentArg(v) {
|
||||
if (v === null) throw usage("--comment N is required");
|
||||
if (!/^[1-9][0-9]{0,15}$/.test(v) || !Number.isSafeInteger(Number(v))) throw usage(`--comment must be a comment id: ${JSON.stringify(v)}`);
|
||||
return Number(v);
|
||||
}
|
||||
|
||||
function review(pos, flags, f, change, opts) {
|
||||
const [sub, ...rest] = pos;
|
||||
switch (sub) {
|
||||
case "request":
|
||||
allow(flags, CHANGE); positional(rest, 1, "review request ID");
|
||||
return change("review-request", { id: intArg(rest[0], "ID") });
|
||||
case "resolve":
|
||||
allow(flags, [...CHANGE, "--comment"]); positional(rest, 2, "review resolve ID ATTEMPT");
|
||||
return change("review-resolve", { id: intArg(rest[0], "ID"), attempt: rest[1], comment: commentArg(f("--comment")) });
|
||||
case "abandon":
|
||||
allow(flags, [...CHANGE, "--reason", "--yes"]); positional(rest, 2, "review abandon ID ATTEMPT");
|
||||
if (f("--reason") === null) throw usage("review abandon needs --reason");
|
||||
return change("review-abandon", { id: intArg(rest[0], "ID"), attempt: rest[1], reason: f("--reason") }, { yes: flags.has("--yes") });
|
||||
case "record":
|
||||
allow(flags, [...CHANGE, "--verdict", "--comment", "--candidate"]); positional(rest, 1, "review record ID");
|
||||
for (const k of ["--verdict", "--candidate"]) if (f(k) === null) throw usage(`review record needs ${k}`);
|
||||
return change("review-record", { id: intArg(rest[0], "ID"), verdict: f("--verdict"), comment: commentArg(f("--comment")), candidate: f("--candidate") });
|
||||
case "verify-commit":
|
||||
allow(flags, []); positional(rest, 2, "review verify-commit ID REF");
|
||||
return verifyCommit(opts, intArg(rest[0], "ID"), rest[1]);
|
||||
default:
|
||||
throw usage("review takes request, resolve, abandon, record or verify-commit");
|
||||
}
|
||||
}
|
||||
|
||||
function afterArg(v) {
|
||||
const m = /^([1-9][0-9]{0,6})(?::(done|settled))?$/.exec(v);
|
||||
if (!m) throw usage(`--after takes ID or ID:settled: ${JSON.stringify(v)}`);
|
||||
@@ -161,6 +203,8 @@ export function run(argv, opts = {}) {
|
||||
allow(flags, [...CHANGE, "--reason", "--yes"]); positional(pos, 0, "only options");
|
||||
if (f("--reason") === null) throw usage("accept-history needs --reason");
|
||||
return change("accept-history", { reason: f("--reason") }, { yes: flags.has("--yes") });
|
||||
case "review":
|
||||
return review(pos, flags, f, change, opts);
|
||||
case "render":
|
||||
allow(flags, ["--check"]); positional(pos, 0, "no arguments");
|
||||
return renderView(opts, { check: flags.has("--check") });
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// Piece D (8.9): the request comment and its transport. The queue never
|
||||
// reads a token. It checks the acting seat's credential file with lstat
|
||||
// only, and `scripts/gitea-api.sh`, the one reader, sends the token to curl
|
||||
// through a config stream. Every call runs under a hard deadline.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { lstatSync, realpathSync } from "node:fs";
|
||||
import { isAbsolute, join, resolve } from "node:path";
|
||||
import { FAILED_CODES } from "./queue.mjs";
|
||||
|
||||
export const REPO_API = "repos/mosaicstack/stack";
|
||||
export const DEFAULT_DEADLINE_MS = 30000;
|
||||
const LOGIN_RE = /^[a-z0-9][a-z0-9._-]{0,38}$/;
|
||||
const MAX_BODY = 60000;
|
||||
|
||||
// The Gitea account a seat posts as. The lead's is jarvis (lead decision 37).
|
||||
export function loginFor(actor) {
|
||||
return actor === "sage" ? "jarvis" : actor;
|
||||
}
|
||||
|
||||
// The acting seat's own token file, checked without opening it. Returns
|
||||
// null when it passes, else the reason.
|
||||
export function credCheck(env, actor) {
|
||||
const login = loginFor(actor);
|
||||
const p = env.MOSAIC_GITEA_CREDENTIAL_FILE;
|
||||
if (p === undefined || p === "") return `MOSAIC_GITEA_CREDENTIAL_FILE is not set; a request posts only with ${login}'s own token file`;
|
||||
if (!isAbsolute(p)) return "MOSAIC_GITEA_CREDENTIAL_FILE must be an absolute path";
|
||||
if (env.HOME && resolve(p) === resolve(env.HOME, "secrets/mosaic.gitea.json")) return "MOSAIC_GITEA_CREDENTIAL_FILE names the shared default file; a request posts only with the seat's own token file";
|
||||
const want = `/agents/${login}/secrets/gitea-mosaicstack-${login}.token`;
|
||||
if (!resolve(p).endsWith(want)) return `MOSAIC_GITEA_CREDENTIAL_FILE is not ${login}'s token file (…${want})`;
|
||||
let st;
|
||||
try {
|
||||
st = lstatSync(p);
|
||||
} catch {
|
||||
return `${login}'s token file does not exist`;
|
||||
}
|
||||
if (st.isSymbolicLink() || !st.isFile()) return `${login}'s token file must be a regular file, not a symlink`;
|
||||
// A linked directory must not lead to another seat's file.
|
||||
if (!realpathSync(p).endsWith(want)) return `MOSAIC_GITEA_CREDENTIAL_FILE resolves outside ${login}'s secrets directory`;
|
||||
if (typeof process.getuid === "function" && st.uid !== process.getuid()) return `${login}'s token file is not owned by this user`;
|
||||
if ((st.mode & 0o777) !== 0o600) return `${login}'s token file must be mode 0600`;
|
||||
return null;
|
||||
}
|
||||
|
||||
// The two markers `review resolve` checks in a comment it is shown.
|
||||
export function markers(op, row, round, digest) {
|
||||
return [`<!-- mosaic-queue-op: ${op} -->`, `<!-- mosaic-queue-round: row=${row} round=${round} candidate=${digest} -->`];
|
||||
}
|
||||
|
||||
function fence(text) {
|
||||
const longest = Math.max(0, ...(text.match(/`+/g) ?? []).map((s) => s.length));
|
||||
return "`".repeat(Math.max(3, longest + 1));
|
||||
}
|
||||
|
||||
// The request comment for one attempt.
|
||||
export function requestBody(row, round, op) {
|
||||
const c = round.candidate;
|
||||
const lines = [
|
||||
...markers(op, row.id, round.n, c.digest),
|
||||
"",
|
||||
`Review request for queue row ${row.id}, round ${round.n}: ${row.piece}`,
|
||||
"",
|
||||
`- Owner: ${row.owner}`,
|
||||
`- Reviewers: ${row.reviewers.join(", ")}`,
|
||||
`- Gate: ${row.gate} (${row.gateOwner})`,
|
||||
`- Brief: ${row.brief ? `\`${row.brief.path}\` § ${row.brief.anchor} @${row.brief.blob.slice(0, 12)}` : "none"}`,
|
||||
`- Candidate: ${c.kind} \`${c.digest}\``,
|
||||
"",
|
||||
];
|
||||
if (c.kind === "manifest") {
|
||||
const f = fence(c.text);
|
||||
lines.push("The manifest:", "", `${f}text`, c.text.replace(/\n$/, ""), f, "");
|
||||
lines.push(`Check a tree against it with \`scripts/mosaic queue review verify-commit ${row.id} REF\`.`, "");
|
||||
} else {
|
||||
lines.push(`Check a prospective commit against it with \`scripts/mosaic queue review verify-commit ${row.id} REF\`.`, "");
|
||||
}
|
||||
lines.push(
|
||||
"Post your verdict as a comment here, then record it:",
|
||||
"",
|
||||
"```",
|
||||
`scripts/mosaic queue review record ${row.id} --verdict approve|changes --comment COMMENT_ID --candidate ${c.digest} --op OP --by SEAT`,
|
||||
"```",
|
||||
);
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
export function bodyTooLong(body) {
|
||||
return Buffer.byteLength(body) > MAX_BODY;
|
||||
}
|
||||
|
||||
// One helper call under the deadline. `timeout -s KILL` kills the helper's
|
||||
// whole process group, curl included, so nothing runs on after it.
|
||||
export function callTool(ctx, top, args) {
|
||||
const tool = join(top, "scripts/gitea-api.sh");
|
||||
const secs = String(ctx.deadlineMs / 1000);
|
||||
const r = spawnSync("timeout", ["-s", "KILL", secs, tool, ...args], { cwd: top, env: ctx.env, encoding: "utf8", maxBuffer: 16 << 20 });
|
||||
const m = /^HTTP (\d{3})$/m.exec(r.stderr ?? "");
|
||||
return {
|
||||
spawnFailed: r.error?.code === "ENOENT",
|
||||
killed: r.signal === "SIGKILL" || r.status === 137,
|
||||
error: r.error ? true : false,
|
||||
exit: r.status,
|
||||
http: m ? Number(m[1]) : null,
|
||||
requestFailed: /^gitea-api: request failed$/m.test(r.stderr ?? ""),
|
||||
stdout: r.stdout ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function jsonOrNull(text) {
|
||||
try { return JSON.parse(text); } catch { return null; }
|
||||
}
|
||||
|
||||
// The POST's outcome. Details are fixed text: nothing from the response is
|
||||
// recorded or echoed.
|
||||
export function classifyPost(r) {
|
||||
const out = (outcome, status, comment, detail) => ({ outcome, status, comment, detail });
|
||||
if (r.spawnFailed) return out("failed", null, null, "pre-send: the timeout command could not run");
|
||||
if (r.killed) return out("uncertain", null, null, "no answer before the deadline");
|
||||
if (r.error) return out("uncertain", r.http, null, "the helper call failed");
|
||||
if (r.http === 201) {
|
||||
const j = jsonOrNull(r.stdout);
|
||||
const id = j && typeof j === "object" ? j.id : undefined;
|
||||
if (Number.isSafeInteger(id) && id > 0) return out("posted", 201, id, "created");
|
||||
return out("uncertain", 201, null, "HTTP 201 without a comment id");
|
||||
}
|
||||
if (r.http !== null && FAILED_CODES.includes(r.http)) return out("failed", r.http, null, `refused with HTTP ${r.http}`);
|
||||
if (r.http !== null) return out("uncertain", r.http, null, `unexpected HTTP ${r.http}`);
|
||||
if (r.requestFailed) return out("uncertain", null, null, "the request failed in transit");
|
||||
return out("uncertain", null, null, "no HTTP status came back");
|
||||
}
|
||||
|
||||
// GET user: the token must belong to the acting seat's login. Returns null
|
||||
// or the reason, which is safe to print.
|
||||
export function checkUser(r, login) {
|
||||
if (r.spawnFailed) return "the timeout command could not run";
|
||||
if (r.killed) return "GET user had no answer before the deadline";
|
||||
if (r.error || r.http === null) return "GET user failed";
|
||||
if (r.http !== 200) return `GET user answered HTTP ${r.http}`;
|
||||
const j = jsonOrNull(r.stdout);
|
||||
const got = j && typeof j === "object" ? j.login : undefined;
|
||||
if (got === login) return null;
|
||||
const shown = typeof got === "string" && LOGIN_RE.test(got) ? got : "an unexpected value";
|
||||
return `the token belongs to ${shown}, not ${login}`;
|
||||
}
|
||||
|
||||
// GET issues/comments/ID for `review resolve`: the comment must be on the
|
||||
// round's issue and carry both of the attempt's markers.
|
||||
export function checkComment(r, { id, issue, op, row, round, digest, author }) {
|
||||
if (r.spawnFailed || r.killed || r.error || r.http === null) return { code: 1, reason: `GET comment ${id} failed or had no answer before the deadline` };
|
||||
if (r.http === 404) return { code: 2, reason: `comment ${id} does not exist` };
|
||||
if (r.http !== 200) return { code: 1, reason: `GET comment ${id} answered HTTP ${r.http}` };
|
||||
const j = jsonOrNull(r.stdout);
|
||||
if (!j || typeof j !== "object") return { code: 1, reason: `GET comment ${id} did not answer JSON` };
|
||||
const wrong = [];
|
||||
if (j.id !== id) wrong.push("its id");
|
||||
if (!j.user || j.user.login !== author) wrong.push(`its author (want ${author})`);
|
||||
if (typeof j.issue_url !== "string" || !j.issue_url.endsWith(`/issues/${issue}`)) wrong.push(`the issue (want #${issue})`);
|
||||
const body = typeof j.body === "string" ? j.body.split("\n") : [];
|
||||
const [mOp, mRound] = markers(op, row, round, digest);
|
||||
if (!body.includes(mOp)) wrong.push(`the op marker for ${op}`);
|
||||
if (!body.includes(mRound)) wrong.push(`the round marker (row ${row} round ${round} candidate ${digest})`);
|
||||
return wrong.length ? { code: 2, reason: `comment ${id} does not match request ${op}: ${wrong.join(", ")}` } : null;
|
||||
}
|
||||
@@ -11,10 +11,15 @@ import { QueueError } from "./errors.mjs";
|
||||
import { checkPlatform, errno, fsyncFile, lstatOrNull, readOrNull, realIo, unlinkQuiet, writeTemp } from "./io.mjs";
|
||||
import { acquire, checkGate, realProc, releaseOrWarn, unlock as unlockLock } from "./lock.mjs";
|
||||
import {
|
||||
PRIVILEGED, SEMANTICS, VERSION, applyEntry, buildDoc, canonArgs, checkCallerOpId, checkName, classifyView, countHeading,
|
||||
describeUnshown, genesisReceipt, genesisRows, gitBlobId, loadDoc, logDigest, nextFor, parseBriefSpec, parseManifest,
|
||||
parseMigrationMap, render, rowsArray, sameJson, serialize, sha256, splitView,
|
||||
LOG_OP_RE, PRIVILEGED, SEMANTICS, UNRESOLVED_STATES, VERSION, applyEntry, attemptOf, buildDoc, canonArgs, checkCallerOpId,
|
||||
checkName, classifyView, countHeading, describeUnshown, genesisReceipt, genesisRows, gitBlobId, loadDoc, logDigest,
|
||||
manifestEntries, nextFor, parseBriefSpec, parseManifest, parseMigrationMap, render, rowsArray, sameJson, serialize, sha256,
|
||||
splitView,
|
||||
} from "./queue.mjs";
|
||||
import {
|
||||
DEFAULT_DEADLINE_MS, REPO_API, bodyTooLong, callTool, checkComment, checkUser, classifyPost, credCheck, loginFor, markers,
|
||||
requestBody,
|
||||
} from "./review.mjs";
|
||||
|
||||
export const QUEUE_REL = "docs/plans/queue.json";
|
||||
export const VIEW_REL = "docs/plans/QUEUE.md";
|
||||
@@ -37,6 +42,7 @@ function makeCtx(opts = {}) {
|
||||
readOrder: opts.readOrder ?? "witness-first",
|
||||
lockWaitMs: opts.lockWaitMs ?? 10000,
|
||||
lockStepMs: opts.lockStepMs ?? 100,
|
||||
deadlineMs: opts.deadlineMs ?? DEFAULT_DEADLINE_MS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -458,13 +464,17 @@ function resolveFor(ctx, loc, cur, verb, args, cmp) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Every logged verb except genesis. `yes` is accept-history's confirmation;
|
||||
// it is not part of the op's identity.
|
||||
// Every logged verb except genesis and review-outcome, which only a
|
||||
// request writes. `yes` confirms accept-history and review-abandon; it is
|
||||
// not part of the op's identity.
|
||||
export function mutate(opts, { verb, op, args, by, yes = false }) {
|
||||
const ctx = makeCtx(opts);
|
||||
const mismatch = actorMismatch(ctx, by);
|
||||
try {
|
||||
const res = mutateAs(ctx, { verb, op, args, by, yes });
|
||||
if (verb === "review-outcome") throw refuse("a request records its own outcome; resolve or abandon the attempt instead");
|
||||
if (verb === "review-resolve") checkResolve(ctx, { op, args, by });
|
||||
let res = mutateAs(ctx, { verb, op, args, by, yes });
|
||||
res = verb === "move" || verb === "review-request" ? requestStep(ctx, res) : strip(res);
|
||||
if (mismatch) res.err.unshift(mismatch);
|
||||
return res;
|
||||
} catch (err) {
|
||||
@@ -473,8 +483,13 @@ export function mutate(opts, { verb, op, args, by, yes = false }) {
|
||||
}
|
||||
}
|
||||
|
||||
function mutateAs(ctx, { verb, op, args, by, yes }) {
|
||||
checkCallerOp(op);
|
||||
// `derived` is the outcome write: its op is the attempt's op plus .outcome.
|
||||
function mutateAs(ctx, { verb, op, args, by, yes = false, derived = false }) {
|
||||
if (derived) {
|
||||
if (!LOG_OP_RE.test(op) || op !== `${args.attempt}.outcome`) throw refuse(`outcome op ${op} is not its attempt's op plus .outcome`);
|
||||
} else {
|
||||
checkCallerOp(op);
|
||||
}
|
||||
const actor = actorOf(ctx, by);
|
||||
const cargs = canonArgs(verb, args);
|
||||
if (verb === "genesis") return genesis(ctx, op, cargs, actor);
|
||||
@@ -495,8 +510,12 @@ function mutateAs(ctx, { verb, op, args, by, yes }) {
|
||||
if (view.state === "stale") res.err.push(`warning: ${staleMessage(view, cur.doc.log)}`);
|
||||
if (view.state === "unknown") res.err.push(`warning: ${unknownMessage(view)}`);
|
||||
res.out.push(`${prior.result.receipt} (already recorded at rev ${prior.rev})`);
|
||||
Object.assign(res, { fresh: false, entry: prior, rows: cur.state.rows, top: loc.top });
|
||||
return;
|
||||
}
|
||||
if (verb === "review-abandon" && !yes) {
|
||||
throw refuse("abandoning a request means a request comment may exist twice on the issue; re-run with --yes to record that");
|
||||
}
|
||||
if (verb === "accept-history") {
|
||||
if (cmp.state !== "lost" && cmp.state !== "absent") throw refuse("history is not lost and the witness is present; accept-history has nothing to accept");
|
||||
const range = `${lostMessage(cmp, cur.doc)}. ops in that range are no longer deduplicated`;
|
||||
@@ -527,9 +546,159 @@ function mutateAs(ctx, { verb, op, args, by, yes }) {
|
||||
const warn = commitWrite(ctx, loc, cur, doc, bytes, op, { bytes: viewRead.bytes, parts: view.parts }, body, false);
|
||||
if (warn) res.err.push(`warning: ${warn}`);
|
||||
res.out.push(entry.result.receipt);
|
||||
Object.assign(res, { fresh: true, entry, rows: applied.state.rows, top: loc.top });
|
||||
});
|
||||
}
|
||||
|
||||
// --- the request comment (8.9) ---
|
||||
|
||||
const STATE_CODE = { posted: 0, failed: 1, abandoned: 1 };
|
||||
|
||||
function stateCode(state) {
|
||||
return STATE_CODE[state] ?? 3;
|
||||
}
|
||||
|
||||
function strip(res) {
|
||||
return { out: res.out, err: res.err, code: res.code };
|
||||
}
|
||||
|
||||
function retryHint(row, round, attempt) {
|
||||
const where = `row ${row.id} round ${round.n} on #${round.issue}`;
|
||||
if (attempt.state === "posted") return `request ${attempt.op} is posted (${where}), comment ${attempt.comment}; nothing was sent again`;
|
||||
if (!UNRESOLVED_STATES.includes(attempt.state)) return `request ${attempt.op} is ${attempt.state} (${where}); nothing was sent again`;
|
||||
return `request ${attempt.op} is ${attempt.state} (${where}); nothing was sent again.\n${settleHint(row, round, attempt)}`;
|
||||
}
|
||||
|
||||
// What to do about a request that may or may not be on the issue.
|
||||
function settleHint(row, round, attempt) {
|
||||
return [
|
||||
`Look on #${round.issue} for a comment carrying ${markers(attempt.op, row.id, round.n, round.candidate.digest)[0]}.`,
|
||||
`If it is there: ${FIX} review resolve ${row.id} ${attempt.op} --comment ID --op OP. If not: a privileged actor runs ${FIX} review abandon ${row.id} ${attempt.op} --reason TEXT --yes --op OP.`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
// After a move or review-request is logged: post the request comment once,
|
||||
// then log what the transport said. A retried op never posts again.
|
||||
function requestStep(ctx, res) {
|
||||
const e = res.entry;
|
||||
const row = res.rows.get(e.args.id);
|
||||
const found = row ? attemptOf(row, e.op) : null;
|
||||
if (!found) return strip(res);
|
||||
const { round, attempt } = found;
|
||||
if (!res.fresh) {
|
||||
res.err.push(retryHint(row, round, attempt));
|
||||
return { ...strip(res), code: stateCode(attempt.state) };
|
||||
}
|
||||
const actor = e.by;
|
||||
ctx.hook("pre-send");
|
||||
const body = requestBody(row, round, e.op);
|
||||
let why = credCheck(ctx.env, actor);
|
||||
if (why === null) why = checkUser(callTool(ctx, res.top, ["GET", "user"]), loginFor(actor));
|
||||
let t;
|
||||
if (why !== null) {
|
||||
res.err.push(`request ${e.op} not sent: ${why}`);
|
||||
t = { outcome: "failed", status: null, comment: null, detail: "pre-send: the credential or account check failed" };
|
||||
} else if (bodyTooLong(body)) {
|
||||
res.err.push(`request ${e.op} not sent: the comment would be longer than the limit`);
|
||||
t = { outcome: "failed", status: null, comment: null, detail: "pre-send: the request comment is too long" };
|
||||
} else {
|
||||
const r = callTool(ctx, res.top, ["POST", `${REPO_API}/issues/${round.issue}/comments`, JSON.stringify({ body })]);
|
||||
ctx.hook("posted");
|
||||
t = classifyPost(r);
|
||||
}
|
||||
ctx.hook("outcome");
|
||||
let out;
|
||||
try {
|
||||
out = mutateAs(ctx, { verb: "review-outcome", op: `${e.op}.outcome`, args: { id: row.id, attempt: e.op, ...t }, by: actor, derived: true });
|
||||
} catch (err) {
|
||||
const said = t.outcome === "posted" ? `posted comment ${t.comment}` : `${t.outcome} (${t.detail})`;
|
||||
throw new QueueError(`${[...res.out, ...res.err].join("\n")}\nuncertain ${e.op}: the transport said ${said}; that outcome is not recorded: ${err.message}`, 3);
|
||||
}
|
||||
const now = attemptOf(out.rows.get(row.id), e.op);
|
||||
const err = [...res.err, ...out.err];
|
||||
if (UNRESOLVED_STATES.includes(now.attempt.state)) err.push(settleHint(out.rows.get(row.id), now.round, now.attempt));
|
||||
return { out: [...res.out, ...out.out], err, code: stateCode(now.attempt.state) };
|
||||
}
|
||||
|
||||
// review resolve checks the comment it names before logging anything: on
|
||||
// the round's issue, by the requester's account, with both markers.
|
||||
function checkResolve(ctx, { op, args, by }) {
|
||||
checkCallerOp(op);
|
||||
const actor = actorOf(ctx, by);
|
||||
const a = canonArgs("review-resolve", args);
|
||||
const st = readStateWith(ctx);
|
||||
if (st.doc.log.some((e) => e.op === op)) return;
|
||||
const row = st.state.rows.get(a.id);
|
||||
const found = row ? attemptOf(row, a.attempt) : null;
|
||||
// Anything the log would refuse anyway is refused there, with no request.
|
||||
if (!found || !UNRESOLVED_STATES.includes(found.attempt.state) || row.state === "done") return;
|
||||
if (actor !== row.owner && !PRIVILEGED.has(actor)) return;
|
||||
const why = credCheck(ctx.env, actor);
|
||||
if (why) throw refuse(`cannot check comment ${a.comment}: ${why}`);
|
||||
const r = callTool(ctx, st.loc.top, ["GET", `${REPO_API}/issues/comments/${a.comment}`]);
|
||||
const { round } = found;
|
||||
const bad = checkComment(r, {
|
||||
id: a.comment, issue: round.issue, op: a.attempt, row: row.id, round: round.n, digest: round.candidate.digest, author: loginFor(found.attempt.by),
|
||||
});
|
||||
if (bad) throw new QueueError(bad.reason, bad.code);
|
||||
}
|
||||
|
||||
// review verify-commit: does REF's tree hold exactly the candidate?
|
||||
export function verifyCommit(opts, id, ref) {
|
||||
const st = readState(opts);
|
||||
const { ctx, loc } = st;
|
||||
const row = st.state.rows.get(id);
|
||||
if (!row) throw refuse(`no row ${id}`);
|
||||
const cur = row.review?.rounds.at(-1);
|
||||
if (!cur) throw refuse(`row ${id} has no review round`);
|
||||
const tree = git(ctx, loc.top, ["rev-parse", "--verify", "--quiet", "--end-of-options", `${ref}^{tree}`], { allowFail: true });
|
||||
if (tree === null) throw refuse(`${ref} is not a commit or tree here`);
|
||||
const have = lsTree(ctx, loc.top, tree.toString().trim());
|
||||
const c = cur.candidate;
|
||||
const bad = [];
|
||||
let count = 0;
|
||||
if (c.kind === "manifest") {
|
||||
for (const { digest, path } of manifestEntries(c.text)) {
|
||||
count++;
|
||||
const h = have.get(path);
|
||||
if (!h || h.type !== "blob") bad.push(`${path}: missing`);
|
||||
else if (sha256(git(ctx, loc.top, ["cat-file", "blob", h.oid])) !== digest) bad.push(`${path}: content differs`);
|
||||
}
|
||||
} else {
|
||||
if (git(ctx, loc.top, ["cat-file", "-e", `${c.digest}^{commit}`], { allowFail: true }) === null) {
|
||||
throw refuse(`candidate commit ${c.digest} is no longer in this repository; a commit candidate stays checkable only while a ref keeps it`);
|
||||
}
|
||||
const parent = git(ctx, loc.top, ["rev-parse", "--verify", "--quiet", `${c.digest}^1`], { allowFail: true });
|
||||
const base = parent === null ? git(ctx, loc.top, ["hash-object", "-t", "tree", "/dev/null"]).toString().trim() : parent.toString().trim();
|
||||
const raw = git(ctx, loc.top, ["diff-tree", "-r", "--no-renames", "-z", base, c.digest]).toString().split("\0");
|
||||
for (let i = 0; i + 1 < raw.length; i += 2) {
|
||||
const m = /^:(\d{6}) (\d{6}) ([0-9a-f]{40}) ([0-9a-f]{40}) ([A-Z])/.exec(raw[i]);
|
||||
if (!m) throw new QueueError(`git diff-tree printed a line this check does not read: ${JSON.stringify(raw[i].slice(0, 80))}`, 1);
|
||||
const path = raw[i + 1];
|
||||
count++;
|
||||
const h = have.get(path);
|
||||
if (m[5] === "D") {
|
||||
if (h) bad.push(`${path}: deleted in the candidate, present here`);
|
||||
} else if (!h) {
|
||||
bad.push(`${path}: missing`);
|
||||
} else if (h.oid !== m[4] || h.mode !== m[2]) {
|
||||
bad.push(`${path}: ${h.oid !== m[4] ? "content" : "mode"} differs`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bad.length) throw refuse(`${ref} does not match row ${id} round ${cur.n}'s candidate:\n${bad.join("\n")}`);
|
||||
return { out: [`ok row ${id} round ${cur.n}: ${ref} matches the ${c.kind} candidate (${count} paths)`], err: st.notes, code: 0 };
|
||||
}
|
||||
|
||||
function lsTree(ctx, top, tree) {
|
||||
const map = new Map();
|
||||
for (const e of git(ctx, top, ["ls-tree", "-r", "-z", "--full-tree", tree]).toString().split("\0")) {
|
||||
const m = /^(\d{6}) (\w+) ([0-9a-f]{40})\t(.*)$/s.exec(e);
|
||||
if (m) map.set(m[4], { mode: m[1], type: m[2], oid: m[3] });
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// log[0] (8.2). Runs before canonicalRoot exists; its arguments are checked
|
||||
// against this checkout instead.
|
||||
function genesis(ctx, op, args, actor) {
|
||||
@@ -627,7 +796,10 @@ function viewNotes(ctx, loc, log) {
|
||||
}
|
||||
|
||||
function readState(opts) {
|
||||
const ctx = makeCtx(opts);
|
||||
return readStateWith(makeCtx(opts));
|
||||
}
|
||||
|
||||
function readStateWith(ctx) {
|
||||
const loc = locate(ctx);
|
||||
const r = readUnlocked(ctx, loc);
|
||||
return { ctx, loc, ...r, notes: [...r.notes, ...viewNotes(ctx, loc, r.doc.log)] };
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
// A stand-in for scripts/gitea-api.sh in the review tests: same argv
|
||||
// (METHOD PATH [BODY]), same output (body on stdout, "HTTP <code>" on
|
||||
// stderr, exit 1 on a non-2xx code). It reads no credential file; the login
|
||||
// comes from the credential path's agents/<login>/ segment. Every call is
|
||||
// appended to $FAKE_GITEA_LOG, and posted comments are served back from it.
|
||||
// $FAKE_GITEA_SCENARIO names a JSON file of rules that override the
|
||||
// defaults: {rules: [{method, path (regex), http, body, sleepMs, fail}]}.
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
|
||||
const [method, path, body] = process.argv.slice(2);
|
||||
const env = process.env;
|
||||
const read = (f) => (f && existsSync(f) ? readFileSync(f, "utf8") : null);
|
||||
const scenario = JSON.parse(read(env.FAKE_GITEA_SCENARIO) ?? "{}");
|
||||
const prior = (read(env.FAKE_GITEA_LOG) ?? "").split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
||||
const cred = env.MOSAIC_GITEA_CREDENTIAL_FILE ?? "";
|
||||
const loginOf = (p) => /\/agents\/([^/]+)\/secrets\//.exec(p)?.[1] ?? null;
|
||||
const login = loginOf(cred);
|
||||
appendFileSync(env.FAKE_GITEA_LOG, `${JSON.stringify({ method, path, body: body === undefined ? null : JSON.parse(body), cred })}\n`);
|
||||
|
||||
function answer(http, obj) {
|
||||
if (obj !== undefined) process.stdout.write(typeof obj === "string" ? obj : JSON.stringify(obj));
|
||||
process.stderr.write(`HTTP ${http}\n`);
|
||||
if (http < 200 || http > 299) {
|
||||
process.stderr.write(`gitea-api: ${method} ${path} failed (HTTP ${http})\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const issueUrl = (n) => `https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/${n}`;
|
||||
const rule = (scenario.rules ?? []).find((r) => r.method === method && new RegExp(r.path).test(path));
|
||||
if (rule) {
|
||||
if (rule.sleepMs) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, rule.sleepMs);
|
||||
if (rule.fail) {
|
||||
process.stderr.write("gitea-api: request failed\n");
|
||||
process.exit(1);
|
||||
}
|
||||
if (rule.http) answer(rule.http, rule.body);
|
||||
}
|
||||
let m;
|
||||
if (method === "GET" && path === "user") answer(200, { login });
|
||||
if (method === "POST" && (m = /^repos\/mosaicstack\/stack\/issues\/(\d+)\/comments$/.exec(path))) {
|
||||
const id = 1000 + prior.filter((p) => p.method === "POST").length;
|
||||
answer(201, { id, issue_url: issueUrl(m[1]), body: JSON.parse(body).body, user: { login } });
|
||||
}
|
||||
if (method === "GET" && (m = /^repos\/mosaicstack\/stack\/issues\/comments\/(\d+)$/.exec(path))) {
|
||||
const id = Number(m[1]);
|
||||
const post = prior.filter((p) => p.method === "POST")[id - 1000];
|
||||
if (post) answer(200, { id, issue_url: issueUrl(/issues\/(\d+)\/comments$/.exec(post.path)[1]), body: post.body.body, user: { login: loginOf(post.cred) } });
|
||||
answer(404, { message: "not found" });
|
||||
}
|
||||
answer(404, { message: "no route" });
|
||||
+6
-3
@@ -1,10 +1,13 @@
|
||||
// Child process for the SIGKILL tests. argv: SRC_DIR STEP REQUEST_JSON.
|
||||
// Runs one mutation from the working directory and kills itself with
|
||||
// SIGKILL when the write path reaches STEP (8.5).
|
||||
// SIGKILL when the write path reaches STEP (8.5). STEP#N kills at the Nth
|
||||
// time the step is reached: a request's outcome write is its second lock.
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const [src, step, json] = process.argv.slice(2);
|
||||
const [src, spec, json] = process.argv.slice(2);
|
||||
const [step, nth = "1"] = spec.split("#");
|
||||
let seen = 0;
|
||||
const store = await import(pathToFileURL(join(src, "store.mjs")).href);
|
||||
store.mutate({ hook: (name) => { if (name === step) process.kill(process.pid, "SIGKILL"); } }, JSON.parse(json));
|
||||
store.mutate({ hook: (name) => { if (name === step && ++seen === Number(nth)) process.kill(process.pid, "SIGKILL"); } }, JSON.parse(json));
|
||||
process.stdout.write("finished without reaching the step\n");
|
||||
|
||||
@@ -0,0 +1,664 @@
|
||||
// Piece D (8.9): the request comment, its outcome, resolve and abandon,
|
||||
// recorded verdicts and verify-commit. Every test posts to
|
||||
// fixtures/fake-gitea.mjs, installed as the scratch repository's
|
||||
// scripts/gitea-api.sh. The token files are dummies; nothing reads them.
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { cli, genesisCommitted, load, opts, scratchRepo } from "./helpers.mjs";
|
||||
|
||||
const HERE = new URL(".", import.meta.url).pathname;
|
||||
const FAKE = join(HERE, "fixtures", "fake-gitea.mjs");
|
||||
const LOGINS = ["darkwing", "dewey", "filbert", "rocko", "jarvis", "sage"];
|
||||
|
||||
async function ready(t, { start = true } = {}) {
|
||||
const repo = scratchRepo(t);
|
||||
genesisCommitted(repo);
|
||||
mkdirSync(join(repo.root, "scripts"), { recursive: true });
|
||||
writeFileSync(join(repo.root, "scripts/gitea-api.sh"), `#!/bin/sh\nexec "${process.execPath}" "${FAKE}" "$@"\n`, { mode: 0o755 });
|
||||
const logFile = join(repo.base, "gitea.log");
|
||||
const scenarioFile = join(repo.base, "scenario.json");
|
||||
repo.env = { ...repo.env, FAKE_GITEA_LOG: logFile, FAKE_GITEA_SCENARIO: scenarioFile };
|
||||
const tokens = {};
|
||||
for (const login of LOGINS) {
|
||||
const dir = join(repo.base, "fleet/agents", login, "secrets");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
tokens[login] = join(dir, `gitea-mosaicstack-${login}.token`);
|
||||
writeFileSync(tokens[login], `${"0".repeat(40)}\n`, { mode: 0o600 });
|
||||
}
|
||||
const m = await load(repo);
|
||||
const cred = (seat) => ({ MOSAIC_GITEA_CREDENTIAL_FILE: tokens[seat === "sage" ? "jarvis" : seat] });
|
||||
const s = {
|
||||
repo, m, tokens, cred,
|
||||
run: (args, by, env = {}) => cli(repo, args, { by, env: { ...cred(by), ...env } }),
|
||||
mut: (req, extra = {}) => m.store.mutate(opts(repo, { ...extra, env: { ...repo.env, ...cred(req.by), ...(extra.env ?? {}) } }), req),
|
||||
calls: () => (existsSync(logFile) ? readFileSync(logFile, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l)) : []),
|
||||
posts: () => s.calls().filter((c) => c.method === "POST"),
|
||||
rules: (rules) => writeFileSync(scenarioFile, JSON.stringify({ rules })),
|
||||
doc: () => JSON.parse(readFileSync(repo.queuePath, "utf8")),
|
||||
row: (id) => s.doc().rows.find((r) => r.id === id),
|
||||
round: (id, n) => s.row(id).review.rounds[n - 1],
|
||||
head: () => repo.g("rev-parse", "HEAD").trim(),
|
||||
};
|
||||
if (start) {
|
||||
ok(s.run(["move", "6", "blocked", "--reason", "paused", "--op", "block-6-00001"], "darkwing"));
|
||||
ok(s.run(["move", "9", "in-progress", "--op", "start-9-00001"], "darkwing"));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function ok(r, re) {
|
||||
assert.equal(r.code, 0, `exit ${r.code}: ${r.out}${r.err}`);
|
||||
if (re) assert.match(r.out, re);
|
||||
return r;
|
||||
}
|
||||
|
||||
function no(r, code, re) {
|
||||
assert.equal(r.code, code, `expected exit ${code}, got ${r.code}: ${r.out}${r.err}`);
|
||||
if (re) assert.match(r.err, re);
|
||||
return r;
|
||||
}
|
||||
|
||||
function throwsCode(fn, code, re) {
|
||||
assert.throws(fn, (err) => {
|
||||
assert.equal(err.code, code, err.message);
|
||||
if (re) assert.match(err.message, re);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const move9 = (op = "review-9-00001") => ["move", "9", "in-review", "--candidate", "HEAD", "--op", op];
|
||||
const request = (op, id = 9) => ["review", "request", String(id), "--op", op];
|
||||
|
||||
// A commit on refs/heads/NAME: PARENT plus CHANGES ({path: text or null}),
|
||||
// built in a private index so the working tree never moves.
|
||||
function commitOn(repo, name, parent, changes) {
|
||||
const env = { ...repo.env, GIT_INDEX_FILE: join(repo.base, `index-${name}`) };
|
||||
const g = (args, input) => {
|
||||
const r = spawnSync("git", ["-C", repo.root, ...args], { env, input, encoding: "utf8" });
|
||||
if (r.status !== 0) throw new Error(`git ${args.join(" ")}: ${r.stderr}`);
|
||||
return r.stdout.trim();
|
||||
};
|
||||
g(["read-tree", parent]);
|
||||
for (const [path, text] of Object.entries(changes)) {
|
||||
if (text === null) g(["update-index", "--force-remove", path]);
|
||||
else g(["update-index", "--add", "--cacheinfo", `100644,${g(["hash-object", "-w", "--stdin"], text)},${path}`]);
|
||||
}
|
||||
const c = g(["commit-tree", g(["write-tree"]), "-p", parent, "-m", name]);
|
||||
g(["update-ref", `refs/heads/${name}`, c]);
|
||||
return c;
|
||||
}
|
||||
|
||||
test("a request posts once as the requester; a retry sends nothing", async (t) => {
|
||||
const s = await ready(t);
|
||||
const head = s.head();
|
||||
const r = ok(s.run(move9(), "darkwing"), /^ok review-9-00001 rev 3 row 9 in-progress→in-review round 1 on #1508; request review-9-00001 requesting$/m);
|
||||
assert.match(r.out, /^ok review-9-00001\.outcome rev 4 row 9 round 1 request review-9-00001 posted comment 1000$/m);
|
||||
const calls = s.calls();
|
||||
assert.deepEqual(calls.map((c) => `${c.method} ${c.path}`), ["GET user", "POST repos/mosaicstack/stack/issues/1508/comments"]);
|
||||
assert.ok(calls.every((c) => c.cred === s.tokens.darkwing));
|
||||
const body = calls[1].body.body;
|
||||
assert.ok(body.startsWith(`<!-- mosaic-queue-op: review-9-00001 -->\n<!-- mosaic-queue-round: row=9 round=1 candidate=${head} -->\n`), body);
|
||||
assert.match(body, new RegExp(`queue review record 9 --verdict approve\\|changes --comment COMMENT_ID --candidate ${head}`));
|
||||
assert.match(body, /- Reviewers: filbert\n/);
|
||||
const round = s.round(9, 1);
|
||||
assert.deepEqual(Object.keys(round), ["n", "op", "by", "at", "issue", "candidate", "request", "attempts", "duplicateRisk", "receipts"]);
|
||||
assert.equal(round.request, "comment");
|
||||
assert.deepEqual(round.attempts.map((a) => [a.op, a.by, a.state, a.comment, a.transport]), [
|
||||
["review-9-00001", "darkwing", "posted", 1000, { outcome: "posted", status: 201, comment: 1000, detail: "created" }],
|
||||
]);
|
||||
assert.equal(s.doc().log.at(-1).op, "review-9-00001.outcome");
|
||||
assert.match(readFileSync(s.repo.viewPath, "utf8"), /; in-review, round 1, request posted \|/);
|
||||
const again = ok(s.run(move9(), "darkwing"), /\(already recorded at rev 3\)$/m);
|
||||
assert.match(again.err, /request review-9-00001 is posted \(row 9 round 1 on #1508\), comment 1000; nothing was sent again/);
|
||||
assert.equal(s.calls().length, 2);
|
||||
no(s.run(request("request-9-0002"), "darkwing"), 2, /round 1 already has a posted request \(review-9-00001, comment 1000\)/);
|
||||
no(s.run(["review", "outcome", "9"], "darkwing"), 4, /review takes request, resolve, abandon, record or verify-commit/);
|
||||
throwsCode(() => s.mut({ verb: "review-outcome", op: "review-9-00001.outcome", args: { id: 9, attempt: "review-9-00001", outcome: "failed", status: 403, comment: null, detail: "x" }, by: "darkwing" }), 2, /records its own outcome/);
|
||||
});
|
||||
|
||||
test("each transport answer maps to posted, failed or uncertain (8.9 step 3)", async (t) => {
|
||||
const s = await ready(t);
|
||||
s.rules([{ method: "POST", path: "comments$", http: 403, body: { message: "forbidden" } }]);
|
||||
const first = no(s.run(move9(), "darkwing"), 1);
|
||||
assert.match(first.out, /^ok review-9-00001\.outcome rev 4 row 9 round 1 request review-9-00001 failed$/m);
|
||||
let n = 1;
|
||||
const cases = [
|
||||
[{ http: 400 }, 1, "failed", 400], [{ http: 401 }, 1, "failed", 401], [{ http: 404 }, 1, "failed", 404], [{ http: 422 }, 1, "failed", 422],
|
||||
[{ http: 500 }, 3, "uncertain", 500], [{ http: 201, body: { nope: true } }, 3, "uncertain", 201], [{ http: 201, body: "not json" }, 3, "uncertain", 201],
|
||||
[{ http: 200, body: { id: 5 } }, 3, "uncertain", 200], [{ fail: true }, 3, "uncertain", null],
|
||||
];
|
||||
for (const [rule, code, state, status] of cases) {
|
||||
s.rules([{ method: "POST", path: "comments$", ...rule }]);
|
||||
const op = `request-9-${String(++n).padStart(4, "0")}`;
|
||||
const r = no(s.run(request(op), "darkwing"), code);
|
||||
assert.match(r.out, new RegExp(`request ${op} ${state}$`, "m"));
|
||||
const a = s.round(9, 1).attempts.at(-1);
|
||||
assert.deepEqual([a.op, a.state, a.comment, a.transport.status], [op, state, null, status]);
|
||||
assert.ok(!JSON.stringify(a.transport).includes("forbidden"));
|
||||
if (state === "uncertain") {
|
||||
no(s.run(["review", "abandon", "9", op, "--reason", "not posted", "--op", `abandon-${op}`], "sage"), 2, /re-run with --yes/);
|
||||
no(s.run(["review", "abandon", "9", op, "--reason", "not posted", "--yes", "--op", `abandon-${op}`], "darkwing"), 2, /only a privileged actor/);
|
||||
ok(s.run(["review", "abandon", "9", op, "--reason", "not posted", "--yes", "--op", `abandon-${op}`], "sage"), /uncertain→abandoned; a duplicate request comment may exist$/m);
|
||||
}
|
||||
}
|
||||
assert.equal(s.round(9, 1).duplicateRisk, true);
|
||||
s.rules([{ method: "POST", path: "comments$", sleepMs: 5000 }]);
|
||||
const t0 = Date.now();
|
||||
const late = s.mut({ verb: "review-request", op: "request-9-late1", args: { id: 9 }, by: "darkwing" }, { deadlineMs: 300 });
|
||||
assert.ok(Date.now() - t0 < 4000, "the deadline killed the helper");
|
||||
assert.equal(late.code, 3);
|
||||
assert.deepEqual(s.round(9, 1).attempts.at(-1).transport, { outcome: "uncertain", status: null, comment: null, detail: "no answer before the deadline" });
|
||||
ok(s.run(["review", "abandon", "9", "request-9-late1", "--reason", "timed out", "--yes", "--op", "abandon-late1"], "sage"));
|
||||
s.rules([]);
|
||||
ok(s.run(request("request-9-final"), "darkwing"), /request request-9-final posted comment \d+$/m);
|
||||
assert.match(readFileSync(s.repo.viewPath, "utf8"), /; in-review, round 1, request posted \|/);
|
||||
});
|
||||
|
||||
test("the pre-send checks: GET user must name the requester, under the deadline", async (t) => {
|
||||
const s = await ready(t);
|
||||
s.rules([{ method: "GET", path: "^user$", http: 200, body: { login: "dewey" } }]);
|
||||
const r = no(s.run(move9(), "darkwing"), 1, /request review-9-00001 not sent: the token belongs to dewey, not darkwing/);
|
||||
assert.match(r.out, /request review-9-00001 failed$/m);
|
||||
assert.deepEqual(s.round(9, 1).attempts[0].transport, { outcome: "failed", status: null, comment: null, detail: "pre-send: the credential or account check failed" });
|
||||
s.rules([{ method: "GET", path: "^user$", http: 200, body: { login: "Bad Name\n" } }]);
|
||||
no(s.run(request("request-9-0002"), "darkwing"), 1, /the token belongs to an unexpected value, not darkwing/);
|
||||
s.rules([{ method: "GET", path: "^user$", http: 401, body: { message: "bad token" } }]);
|
||||
const unauth = no(s.run(request("request-9-0003"), "darkwing"), 1, /GET user answered HTTP 401/);
|
||||
assert.ok(!unauth.err.includes("bad token"));
|
||||
s.rules([{ method: "GET", path: "^user$", sleepMs: 5000 }]);
|
||||
const slow = s.mut({ verb: "review-request", op: "request-9-0004", args: { id: 9 }, by: "darkwing" }, { deadlineMs: 300 });
|
||||
assert.equal(slow.code, 1);
|
||||
assert.match(slow.err.join("\n"), /GET user had no answer before the deadline/);
|
||||
assert.equal(s.posts().length, 0);
|
||||
// The lead posts as jarvis, with jarvis's file; a token for "sage" is refused.
|
||||
s.rules([]);
|
||||
ok(s.run(request("request-9-lead1"), "sage"), /request request-9-lead1 posted comment 1000$/m);
|
||||
assert.equal(s.posts()[0].cred, s.tokens.jarvis);
|
||||
assert.equal(s.calls().at(-2).cred, s.tokens.jarvis);
|
||||
assert.equal(s.round(9, 1).attempts.at(-1).by, "sage");
|
||||
});
|
||||
|
||||
test("the lead's request refuses a token for login sage", async (t) => {
|
||||
const s = await ready(t);
|
||||
s.rules([{ method: "POST", path: "comments$", http: 403 }]);
|
||||
no(s.run(move9(), "darkwing"), 1);
|
||||
s.rules([{ method: "GET", path: "^user$", http: 200, body: { login: "sage" } }]);
|
||||
no(s.run(request("request-9-0002"), "sage"), 1, /the token belongs to sage, not jarvis/);
|
||||
no(s.run(request("request-9-0003"), "sage", { MOSAIC_GITEA_CREDENTIAL_FILE: s.tokens.sage }), 1, /not jarvis's token file/);
|
||||
assert.equal(s.posts().length, 1);
|
||||
});
|
||||
|
||||
test("the credential file: the seat's own, 0600, no symlink, never the shared default", async (t) => {
|
||||
const s = await ready(t);
|
||||
const { credCheck, loginFor } = await import(pathToFileURL(join(s.repo.root, "packages/queue/src/review.mjs")).href);
|
||||
assert.equal(loginFor("sage"), "jarvis");
|
||||
assert.equal(loginFor("dewey"), "dewey");
|
||||
const env = (p) => ({ HOME: s.repo.home, MOSAIC_GITEA_CREDENTIAL_FILE: p });
|
||||
assert.equal(credCheck(env(s.tokens.darkwing), "darkwing"), null);
|
||||
assert.equal(credCheck(env(s.tokens.jarvis), "sage"), null);
|
||||
assert.match(credCheck({ HOME: s.repo.home }, "darkwing"), /is not set/);
|
||||
assert.match(credCheck(env("fleet/agents/darkwing/secrets/gitea-mosaicstack-darkwing.token"), "darkwing"), /absolute path/);
|
||||
const shared = join(s.repo.home, "secrets/mosaic.gitea.json");
|
||||
mkdirSync(join(s.repo.home, "secrets"));
|
||||
writeFileSync(shared, "{}", { mode: 0o600 });
|
||||
assert.match(credCheck(env(shared), "darkwing"), /shared default file/);
|
||||
assert.match(credCheck(env(s.tokens.dewey), "darkwing"), /not darkwing's token file/);
|
||||
assert.match(credCheck(env(s.tokens.sage), "sage"), /not jarvis's token file/);
|
||||
assert.match(credCheck(env(s.tokens.darkwing.replace("darkwing.token", "darkwing.tokenx")), "darkwing"), /not darkwing's token file/);
|
||||
const gone = join(s.repo.base, "x/agents/darkwing/secrets/gitea-mosaicstack-darkwing.token");
|
||||
assert.match(credCheck(env(gone), "darkwing"), /does not exist/);
|
||||
chmodSync(s.tokens.rocko, 0o640);
|
||||
assert.match(credCheck(env(s.tokens.rocko), "rocko"), /mode 0600/);
|
||||
chmodSync(s.tokens.rocko, 0o400);
|
||||
assert.match(credCheck(env(s.tokens.rocko), "rocko"), /mode 0600/);
|
||||
// A symlinked file, and a symlinked directory leading to another seat's.
|
||||
const link = join(s.repo.base, "l1/agents/filbert/secrets");
|
||||
mkdirSync(link, { recursive: true });
|
||||
symlinkSync(s.tokens.filbert, join(link, "gitea-mosaicstack-filbert.token"));
|
||||
assert.match(credCheck(env(join(link, "gitea-mosaicstack-filbert.token")), "filbert"), /regular file, not a symlink/);
|
||||
mkdirSync(join(s.repo.base, "l2/agents/filbert"), { recursive: true });
|
||||
symlinkSync(join(s.repo.base, "fleet/agents/dewey/secrets"), join(s.repo.base, "l2/agents/filbert/secrets"));
|
||||
writeFileSync(join(s.repo.base, "fleet/agents/dewey/secrets/gitea-mosaicstack-filbert.token"), "x", { mode: 0o600 });
|
||||
assert.match(credCheck(env(join(s.repo.base, "l2/agents/filbert/secrets/gitea-mosaicstack-filbert.token")), "filbert"), /resolves outside filbert's secrets directory/);
|
||||
// End to end: a refused file makes no call at all.
|
||||
chmodSync(s.tokens.darkwing, 0o644);
|
||||
no(s.run(move9(), "darkwing"), 1, /not sent: darkwing's token file must be mode 0600/);
|
||||
no(s.run(request("request-9-0002"), "darkwing", { MOSAIC_GITEA_CREDENTIAL_FILE: s.tokens.dewey }), 1, /not darkwing's token file/);
|
||||
no(s.run(request("request-9-0003"), "darkwing", { MOSAIC_GITEA_CREDENTIAL_FILE: "" }), 1, /is not set/);
|
||||
assert.deepEqual(s.calls(), []);
|
||||
});
|
||||
|
||||
test("an unresolved request blocks a new request, a new round, waiting-on-jason and done", async (t) => {
|
||||
const s = await ready(t);
|
||||
s.rules([{ method: "POST", path: "comments$", http: 502 }]);
|
||||
no(s.run(move9(), "darkwing"), 3, /^Look on #1508 for a comment carrying <!-- mosaic-queue-op: review-9-00001 -->\.$/m);
|
||||
s.rules([]);
|
||||
const head = s.head();
|
||||
no(s.run(request("request-9-0002"), "darkwing"), 2, /unresolved review request: review-9-00001 \(round 1, uncertain\)/);
|
||||
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "77", "--candidate", head, "--op", "record-9-filbert1"], "filbert"));
|
||||
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "filbert"), 2, /unresolved review request: review-9-00001 \(round 1, uncertain\); resolve or abandon it before closing it/);
|
||||
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /unresolved review request: review-9-00001 \(round 1, uncertain\); resolve or abandon it before moving it to waiting-on-jason/);
|
||||
assert.equal(s.posts().length, 1);
|
||||
|
||||
// A POST that lands after the row reached waiting-on-jason: abandoned,
|
||||
// approved and moved while it was in flight. Jason's close then refuses.
|
||||
const s3 = await ready(t);
|
||||
const head3 = s3.head();
|
||||
const hook = (n) => {
|
||||
if (n !== "posted") return;
|
||||
s3.mut({ verb: "review-abandon", op: "abandon-9-0001", args: { id: 9, attempt: "review-9-00001", reason: "gave up" }, by: "sage", yes: true });
|
||||
s3.mut({ verb: "review-record", op: "record-9-filb01", args: { id: 9, verdict: "approve", comment: 88, candidate: head3 }, by: "filbert" });
|
||||
s3.mut({ verb: "move", op: "wait-9-000001", args: { id: 9, to: "waiting-on-jason" }, by: "darkwing" });
|
||||
};
|
||||
assert.equal(s3.mut({ verb: "move", op: "review-9-00001", args: { id: 9, to: "in-review", candidate: "HEAD" }, by: "darkwing" }, { hook }).code, 3);
|
||||
assert.deepEqual([s3.row(9).state, s3.round(9, 1).attempts[0].state], ["waiting-on-jason", "conflict"]);
|
||||
no(s3.run(["move", "9", "done", "--op", "done-9-000002"], "jason"), 2, /unresolved review request: review-9-00001 \(round 1, conflict\); resolve or abandon it before closing it/);
|
||||
|
||||
// Back to in-progress is allowed; the next round is not.
|
||||
const s2 = await ready(t);
|
||||
s2.rules([{ method: "POST", path: "comments$", http: 502 }]);
|
||||
no(s2.run(move9(), "darkwing"), 3);
|
||||
s2.rules([]);
|
||||
ok(s2.run(["move", "9", "in-progress", "--op", "changes-9-0001"], "darkwing"));
|
||||
no(s2.run(["move", "9", "in-review", "--candidate", "HEAD", "--op", "review-9-00002"], "darkwing"), 2, /unresolved review request: review-9-00001 \(round 1, uncertain\); resolve or abandon it before a new round/);
|
||||
assert.equal(s2.posts().length, 1);
|
||||
});
|
||||
|
||||
test("a same-op retry after a kill sends nothing, even with a stale view", async (t) => {
|
||||
for (const step of ["pre-send", "posted", "outcome", "locked#2"]) {
|
||||
const s = await ready(t);
|
||||
const viewBefore = readFileSync(s.repo.viewPath);
|
||||
const req = { verb: "move", op: "review-9-00001", args: { id: 9, to: "in-review", candidate: "HEAD" }, by: "darkwing" };
|
||||
const r = spawnSync(process.execPath, [join(HERE, "fixtures", "kill-at.mjs"), join(s.repo.root, "packages/queue/src"), step, JSON.stringify(req)], {
|
||||
cwd: s.repo.root, env: { ...s.repo.env, ...s.cred("darkwing") }, encoding: "utf8",
|
||||
});
|
||||
assert.equal(r.signal, "SIGKILL", `child did not die at ${step}: ${r.stdout}${r.stderr}`);
|
||||
if (step === "locked#2") ok(cli(s.repo, ["unlock"]), /^removed queue lock \(dead/);
|
||||
const posted = step === "pre-send" ? 0 : 1;
|
||||
assert.equal(s.posts().length, posted, step);
|
||||
assert.equal(s.round(9, 1).attempts[0].state, "requesting");
|
||||
writeFileSync(s.repo.viewPath, viewBefore);
|
||||
const calls = s.calls().length;
|
||||
const again = no(s.run(move9(), "darkwing"), 3);
|
||||
assert.match(again.out, /\(already recorded at rev 3\)$/m);
|
||||
assert.match(again.err, /warning: view stale/);
|
||||
assert.match(again.err, /request review-9-00001 is requesting \(row 9 round 1 on #1508\); nothing was sent again\.\nLook on #1508 for a comment carrying <!-- mosaic-queue-op: review-9-00001 -->\./);
|
||||
assert.equal(s.calls().length, calls, step);
|
||||
ok(cli(s.repo, ["render"]));
|
||||
if (posted) {
|
||||
ok(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "1000", "--op", "resolve-9-00001"], "darkwing"), /requesting→posted comment 1000$/m);
|
||||
assert.equal(s.calls().at(-1).path, "repos/mosaicstack/stack/issues/comments/1000");
|
||||
} else {
|
||||
ok(s.run(["review", "abandon", "9", "review-9-00001", "--reason", "never sent", "--yes", "--op", "abandon-9-0001"], "sage"));
|
||||
ok(s.run(request("request-9-0002"), "darkwing"), /posted comment 1000$/m);
|
||||
}
|
||||
assert.equal(s.posts().length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test("a held lock at the outcome exits 3 and names what the transport said", async (t) => {
|
||||
const s = await ready(t);
|
||||
let handle = null;
|
||||
const hook = (n) => {
|
||||
if (n === "outcome") handle = s.m.lock.acquire({ gitDir: s.repo.gitDir, io: s.m.io.realIo, op: "holder-op-1", verb: "move" });
|
||||
};
|
||||
throwsCode(() => s.mut({ verb: "move", op: "review-9-00001", args: { id: 9, to: "in-review", candidate: "HEAD" }, by: "darkwing" }, { hook, lockWaitMs: 200, lockStepMs: 50 }), 3,
|
||||
/^ok review-9-00001 rev 3 row 9 in-progress→in-review round 1 on #1508; request review-9-00001 requesting\nuncertain review-9-00001: the transport said posted comment 1000; that outcome is not recorded: queue lock held by move holder-op-1/);
|
||||
s.m.lock.releaseOrWarn(handle, s.m.io.realIo);
|
||||
assert.equal(s.round(9, 1).attempts[0].state, "requesting");
|
||||
no(s.run(move9(), "darkwing"), 3, /nothing was sent again/);
|
||||
assert.equal(s.posts().length, 1);
|
||||
});
|
||||
|
||||
test("late outcomes: after an abandon, and after a resolve with the same or another id", async (t) => {
|
||||
const s = await ready(t);
|
||||
const head = s.head();
|
||||
const mv = (op) => ({ verb: "move", op, args: { id: 9, to: "in-review", candidate: "HEAD" }, by: "darkwing" });
|
||||
// Abandoned while the POST was in flight, then the POST lands: conflict.
|
||||
let hook = (n) => { if (n === "posted") s.mut({ verb: "review-abandon", op: "abandon-9-0001", args: { id: 9, attempt: "review-9-00001", reason: "gave up" }, by: "sage", yes: true }); };
|
||||
const r1 = s.mut(mv("review-9-00001"), { hook });
|
||||
assert.equal(r1.code, 3);
|
||||
assert.match(r1.out.at(-1), /request review-9-00001 conflict \(transport posted\)$/);
|
||||
let a = s.round(9, 1).attempts[0];
|
||||
assert.deepEqual([a.state, a.comment, a.transport.comment, a.resolutions.map((x) => x.verb)], ["conflict", null, 1000, ["abandon"]]);
|
||||
assert.equal(s.round(9, 1).duplicateRisk, true);
|
||||
no(s.run(request("request-9-0002"), "darkwing"), 2, /unresolved review request: review-9-00001 \(round 1, conflict\)/);
|
||||
ok(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "1000", "--op", "resolve-9-00001"], "darkwing"), /conflict→posted comment 1000$/m);
|
||||
a = s.round(9, 1).attempts[0];
|
||||
assert.deepEqual([a.state, a.comment, a.resolutions.map((x) => x.verb)], ["posted", 1000, ["abandon", "resolve"]]);
|
||||
no(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "1000", "--op", "resolve-9-00002"], "darkwing"), 2, /is posted; only a requesting, uncertain or conflict request can be resolved/);
|
||||
|
||||
// Round 2: resolved with the id the POST returns, then the outcome agrees.
|
||||
ok(s.run(["move", "9", "in-progress", "--op", "changes-9-0001"], "darkwing"));
|
||||
hook = (n) => { if (n === "posted") s.mut({ verb: "review-resolve", op: "resolve-9-r2-01", args: { id: 9, attempt: "review-9-00002", comment: 1001 }, by: "darkwing" }); };
|
||||
const r2 = s.mut(mv("review-9-00002"), { hook });
|
||||
assert.equal(r2.code, 0);
|
||||
assert.match(r2.out.at(-1), /request review-9-00002 posted comment 1001$/);
|
||||
assert.deepEqual(s.round(9, 2).attempts[0].transport, { outcome: "posted", status: 201, comment: 1001, detail: "created" });
|
||||
|
||||
// Round 3: resolved with another comment that carries the markers, then
|
||||
// the POST returns a different id: conflict.
|
||||
ok(s.run(["move", "9", "in-progress", "--op", "changes-9-0002"], "darkwing"));
|
||||
const marked = `<!-- mosaic-queue-op: review-9-00003 -->\n<!-- mosaic-queue-round: row=9 round=3 candidate=${head} -->\nposted by hand\n`;
|
||||
s.rules([{ method: "GET", path: "comments/5555$", http: 200, body: { id: 5555, issue_url: "https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/1508", body: marked, user: { login: "darkwing" } } }]);
|
||||
hook = (n) => { if (n === "posted") s.mut({ verb: "review-resolve", op: "resolve-9-r3-01", args: { id: 9, attempt: "review-9-00003", comment: 5555 }, by: "darkwing" }); };
|
||||
const r3 = s.mut(mv("review-9-00003"), { hook });
|
||||
assert.equal(r3.code, 3);
|
||||
a = s.round(9, 3).attempts[0];
|
||||
assert.deepEqual([a.state, a.comment, a.transport.comment, a.resolutions.map((x) => [x.verb, x.comment])], ["conflict", null, 1002, [["resolve", 5555]]]);
|
||||
assert.equal(s.round(9, 3).duplicateRisk, false);
|
||||
// A retry of the logged resolve answers from the log and makes no call,
|
||||
// though the attempt is unresolved again.
|
||||
const before = s.calls().length;
|
||||
ok(s.run(["review", "resolve", "9", "review-9-00003", "--comment", "5555", "--op", "resolve-9-r3-01"], "darkwing"), /already recorded/);
|
||||
assert.equal(s.calls().length, before);
|
||||
ok(s.run(["review", "resolve", "9", "review-9-00003", "--comment", "1002", "--op", "resolve-9-r3-02"], "darkwing"), /conflict→posted comment 1002$/m);
|
||||
|
||||
// Rounds 4 and 5: resolved by hand, then the POST answers 500 (the
|
||||
// resolve stands) or 422 (a refusal contradicts it: conflict).
|
||||
for (const [n, comment, http, code, state] of [[4, 5556, 500, 0, "posted"], [5, 5557, 422, 3, "conflict"]]) {
|
||||
ok(s.run(["move", "9", "in-progress", "--op", `changes-9-000${n}`], "darkwing"));
|
||||
const op = `review-9-0000${n}`;
|
||||
const body = `<!-- mosaic-queue-op: ${op} -->\n<!-- mosaic-queue-round: row=9 round=${n} candidate=${head} -->\nposted by hand\n`;
|
||||
s.rules([
|
||||
{ method: "GET", path: `comments/${comment}$`, http: 200, body: { id: comment, issue_url: "https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/1508", body, user: { login: "darkwing" } } },
|
||||
{ method: "POST", path: "comments$", http },
|
||||
]);
|
||||
hook = (step) => { if (step === "posted") s.mut({ verb: "review-resolve", op: `resolve-9-r${n}-01`, args: { id: 9, attempt: op, comment }, by: "darkwing" }); };
|
||||
const r = s.mut(mv(op), { hook });
|
||||
assert.equal(r.code, code, `round ${n}`);
|
||||
a = s.round(9, n).attempts[0];
|
||||
assert.deepEqual([a.state, a.comment, a.transport.status], [state, state === "posted" ? comment : null, http], `round ${n}`);
|
||||
}
|
||||
assert.equal(s.posts().length, 5);
|
||||
});
|
||||
|
||||
test("resolve checks the comment: issue, markers, round, candidate and author", async (t) => {
|
||||
const s = await ready(t);
|
||||
const head = s.head();
|
||||
s.rules([{ method: "POST", path: "comments$", http: 500 }]);
|
||||
no(s.run(move9(), "darkwing"), 3);
|
||||
const good = { id: 7001, issue_url: "https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/1508",
|
||||
body: `intro\n<!-- mosaic-queue-op: review-9-00001 -->\n<!-- mosaic-queue-round: row=9 round=1 candidate=${head} -->\n`, user: { login: "darkwing" } };
|
||||
const bad = [
|
||||
[{ issue_url: good.issue_url.replace("1508", "1509") }, 2, /does not match request review-9-00001: the issue \(want #1508\)/],
|
||||
[{ issue_url: `${good.issue_url}0` }, 2, /the issue/],
|
||||
[{ body: good.body.replace("review-9-00001", "review-9-00009") }, 2, /the op marker for review-9-00001$/m],
|
||||
[{ body: good.body.replace("round=1", "round=2") }, 2, /the round marker/],
|
||||
[{ body: good.body.replace(head, "f".repeat(40)) }, 2, /the round marker \(row 9 round 1 candidate/],
|
||||
[{ body: good.body.replace("-->\n<!--", "--> <!--") }, 2, /the op marker for review-9-00001, the round marker/],
|
||||
[{ body: `x${good.body.replace("\n<!-- mosaic-queue-round", " <!-- mosaic-queue-round")}` }, 2, /the op marker for review-9-00001, the round marker/],
|
||||
[{ user: { login: "dewey" } }, 2, /its author \(want darkwing\)/],
|
||||
[{ id: 7002 }, 2, /its id/],
|
||||
];
|
||||
for (const [patch, code, re] of bad) {
|
||||
s.rules([{ method: "GET", path: "comments/7001$", http: 200, body: { ...good, ...patch } }]);
|
||||
no(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "darkwing"), code, re);
|
||||
}
|
||||
s.rules([{ method: "GET", path: "comments/7001$", http: 404 }]);
|
||||
no(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "darkwing"), 2, /comment 7001 does not exist/);
|
||||
s.rules([{ method: "GET", path: "comments/7001$", http: 500 }]);
|
||||
no(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "darkwing"), 1, /answered HTTP 500/);
|
||||
s.rules([{ method: "GET", path: "comments/7001$", fail: true }]);
|
||||
no(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "darkwing"), 1, /failed or had no answer/);
|
||||
no(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "darkwing", { MOSAIC_GITEA_CREDENTIAL_FILE: s.tokens.dewey }), 2, /cannot check comment 7001: .*not darkwing's token file/);
|
||||
const before = s.calls().length;
|
||||
no(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "rocko"), 2, /^queue: row 9 is claimed by darkwing; only the owner \(darkwing\) or a privileged actor may resolve a request on row 9$/m);
|
||||
assert.equal(s.calls().length, before, "a refused actor makes no request");
|
||||
assert.equal(s.doc().revision, 4);
|
||||
s.rules([{ method: "GET", path: "comments/7001$", http: 200, body: good }]);
|
||||
ok(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "darkwing"), /uncertain→posted comment 7001$/m);
|
||||
const calls = s.calls().length;
|
||||
ok(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "darkwing"), /already recorded/);
|
||||
assert.equal(s.calls().length, calls, "a recorded resolve checks nothing again");
|
||||
const res = s.round(9, 1).attempts[0].resolutions;
|
||||
assert.deepEqual(res.map((x) => [x.verb, x.by, x.comment, x.reason]), [["resolve", "darkwing", 7001, null]]);
|
||||
});
|
||||
|
||||
test("the lead resolves a seat's request: the comment's author is the requester, fetched with the lead's token", async (t) => {
|
||||
const s = await ready(t);
|
||||
const head = s.head();
|
||||
s.rules([{ method: "POST", path: "comments$", fail: true }]);
|
||||
no(s.run(move9(), "darkwing"), 3);
|
||||
const body = `<!-- mosaic-queue-op: review-9-00001 -->\n<!-- mosaic-queue-round: row=9 round=1 candidate=${head} -->\n`;
|
||||
const comment = (login) => ({ id: 7001, issue_url: "https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/1508", body, user: { login } });
|
||||
s.rules([{ method: "GET", path: "comments/7001$", http: 200, body: comment("jarvis") }]);
|
||||
no(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "sage"), 2, /its author \(want darkwing\)/);
|
||||
s.rules([{ method: "GET", path: "comments/7001$", http: 200, body: comment("darkwing") }]);
|
||||
ok(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "sage"), /uncertain→posted comment 7001$/m);
|
||||
assert.equal(s.calls().at(-1).cred, s.tokens.jarvis);
|
||||
assert.deepEqual(s.round(9, 1).attempts[0].resolutions.map((x) => [x.by, x.comment]), [["sage", 7001]]);
|
||||
});
|
||||
|
||||
test("validateRow checks a request round's shape, which every replayed entry must keep", async (t) => {
|
||||
const s = await ready(t);
|
||||
ok(s.run(move9(), "darkwing"));
|
||||
const { queue } = s.m;
|
||||
const d = s.doc();
|
||||
const row9 = d.rows.find((r) => r.id === 9);
|
||||
queue.validateRow(row9);
|
||||
const forge = (fn) => {
|
||||
const copy = structuredClone(row9);
|
||||
fn(copy.review.rounds[0]);
|
||||
return () => queue.validateRow(copy);
|
||||
};
|
||||
assert.throws(forge((r) => { r.duplicateRisk = true; }), /duplicateRisk is true exactly when an attempt was abandoned/);
|
||||
assert.throws(forge((r) => { r.attempts[0].op = "someone-else-1"; }), /first attempt is the move that opened it/);
|
||||
const rec = { reviewer: "filbert", op: "record-9-filb01", at: "2026-09-27T00:00:00.000Z", verdict: "approve", comment: 88, candidate: d.rows.find((r) => r.id === 9).review.rounds[0].candidate.digest };
|
||||
queue.validateRow({ ...row9, review: { rounds: [{ ...row9.review.rounds[0], receipts: [rec] }] } });
|
||||
assert.throws(forge((r) => { r.receipts = [rec, { ...rec, op: "record-9-filb02" }]; }), /a reviewer has one receipt per round/);
|
||||
assert.throws(forge((r) => { r.attempts.push({ ...r.attempts[0] }); }), /a request round names an attempt twice/);
|
||||
});
|
||||
|
||||
test("request, changes, a new candidate, approval: every round pinned; no review files", async (t) => {
|
||||
const s = await ready(t);
|
||||
const c1 = s.head();
|
||||
ok(s.run(move9(), "darkwing"), /posted comment 1000$/m);
|
||||
ok(cli(s.repo, ["next", "filbert"]), /^review row 9: Queue as data/m);
|
||||
no(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "88", "--candidate", c1, "--op", "record-9-dark01"], "darkwing"), 2, /only a listed reviewer other than the owner/);
|
||||
no(s.run(["review", "record", "9", "--verdict", "maybe", "--comment", "88", "--candidate", c1, "--op", "record-9-filb01"], "filbert"), 2, /approve or changes/);
|
||||
no(s.run(["review", "record", "9", "--verdict", "changes", "--candidate", c1, "--op", "record-9-filb01"], "filbert"), 4, /--comment N is required/);
|
||||
ok(s.run(["review", "record", "9", "--verdict", "changes", "--comment", "88", "--candidate", c1, "--op", "record-9-filb01"], "filbert"), /row 9 round 1 changes by filbert \(comment 88\)$/m);
|
||||
no(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "89", "--candidate", c1, "--op", "record-9-filb02"], "filbert"), 2, /filbert already recorded a verdict for row 9 round 1/);
|
||||
ok(cli(s.repo, ["next", "filbert"]), /^nothing$/m);
|
||||
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "filbert"), 2, /round 1 has no approval recorded by filbert/);
|
||||
ok(s.run(["move", "9", "in-progress", "--op", "changes-9-0001"], "darkwing"));
|
||||
no(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "89", "--candidate", c1, "--op", "record-9-filb02"], "filbert"), 2, /is in-progress; review record applies to in-review rows/);
|
||||
writeFileSync(join(s.repo.root, "packages/fix.mjs"), "export {};\n");
|
||||
s.repo.g("add", "packages/fix.mjs");
|
||||
s.repo.g("commit", "-q", "-m", "fix", "--", "packages/fix.mjs");
|
||||
const c2 = s.head();
|
||||
ok(s.run(move9("review-9-00002"), "darkwing"), /round 2 on #1508; request review-9-00002 requesting$/m);
|
||||
ok(cli(s.repo, ["next", "filbert"]), /^review row 9/m);
|
||||
no(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "89", "--candidate", c1, "--op", "record-9-filb02"], "filbert"), 2, new RegExp(`candidate ${c1} is not round 2's candidate ${c2}`));
|
||||
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "89", "--candidate", c2, "--op", "record-9-filb02"], "filbert"));
|
||||
no(s.run(["move", "9", "done", "--evidence", `comment=89,round=2,candidate=${c2}`, "--op", "done-9-000001"], "filbert"), 2, /closes on its recorded verdicts; drop --evidence/);
|
||||
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "rocko"), 2, /only the gate owner \(filbert\) or a privileged actor/);
|
||||
ok(s.run(["move", "9", "done", "--op", "done-9-000001"], "filbert"), /in-review→done round 2$/m);
|
||||
const rounds = s.row(9).review.rounds;
|
||||
assert.deepEqual(rounds.map((r) => [r.n, r.candidate.digest, r.attempts.map((a) => [a.op, a.state, a.comment]), r.receipts.map((x) => [x.reviewer, x.verdict, x.comment, x.candidate])]), [
|
||||
[1, c1, [["review-9-00001", "posted", 1000]], [["filbert", "changes", 88, c1]]],
|
||||
[2, c2, [["review-9-00002", "posted", 1001]], [["filbert", "approve", 89, c2]]],
|
||||
]);
|
||||
no(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "90", "--candidate", c2, "--op", "record-9-filb03"], "filbert"), 2, /row 9 is done; done rows never change/);
|
||||
const untracked = s.repo.g("ls-files", "--others", "--exclude-standard").split("\n").filter(Boolean);
|
||||
assert.deepEqual(untracked.filter((p) => p.startsWith("docs/plans/reviews/") || /^agents\/[^/]+\/work\//.test(p)), []);
|
||||
assert.deepEqual(untracked, ["scripts/gitea-api.sh"]);
|
||||
});
|
||||
|
||||
test("a row with no reviewers opens a round that posts nothing", async (t) => {
|
||||
const s = await ready(t);
|
||||
ok(s.run(["set", "9", "reviewers", "none", "--op", "reviewers-9-none"], "sage"));
|
||||
const r = ok(s.run(move9(), "darkwing"), /in-progress→in-review round 1 on #1508$/m);
|
||||
assert.doesNotMatch(r.out, /request/);
|
||||
assert.deepEqual(Object.keys(s.round(9, 1)), ["n", "op", "by", "at", "issue", "candidate", "request"]);
|
||||
assert.equal(s.round(9, 1).request, "none");
|
||||
no(s.run(request("request-9-0002"), "darkwing"), 2, /posts no request \(the row had no reviewers when it opened\)/);
|
||||
assert.deepEqual(s.calls(), []);
|
||||
|
||||
// The other way round: a request round whose reviewers are then removed
|
||||
// doesn't close on no approvals.
|
||||
const s2 = await ready(t);
|
||||
ok(s2.run(move9(), "darkwing"));
|
||||
ok(s2.run(["set", "9", "reviewers", "none", "--op", "reviewers-9-none"], "sage"));
|
||||
no(s2.run(["move", "9", "done", "--op", "done-9-000001"], "sage"), 2, /row 9 lists no reviewers now; a privileged actor sets them before it closes/);
|
||||
});
|
||||
|
||||
test("verify-commit: a prospective tree must hold exactly the candidate's paths", async (t) => {
|
||||
const s = await ready(t);
|
||||
const base = s.head();
|
||||
const cand = commitOn(s.repo, "cand", base, { "packages/x.mjs": "export const x = 1;\n", "agents/rocko/.keep": null });
|
||||
ok(s.run(["move", "9", "in-review", "--candidate", "cand", "--op", "review-9-00001"], "darkwing"));
|
||||
const other = commitOn(s.repo, "other", base, { "docs/other.md": "other\n" });
|
||||
const good = commitOn(s.repo, "good", other, { "packages/x.mjs": "export const x = 1;\n", "agents/rocko/.keep": null });
|
||||
ok(cli(s.repo, ["review", "verify-commit", "9", "good"]), /^ok row 9 round 1: good matches the commit candidate \(2 paths\)$/m);
|
||||
const changed = commitOn(s.repo, "changed", good, { "packages/x.mjs": "export const x = 2;\n" });
|
||||
no(cli(s.repo, ["review", "verify-commit", "9", "changed"]), 2, /changed does not match row 9 round 1's candidate:\npackages\/x.mjs: content differs$/m);
|
||||
const exec = spawnSync("git", ["-C", s.repo.root, "commit-tree", s.repo.g("rev-parse", "good^{tree}").trim(), "-p", good, "-m", "exec"], { env: s.repo.env, encoding: "utf8" });
|
||||
assert.equal(exec.status, 0, exec.stderr);
|
||||
const execEnv = { ...s.repo.env, GIT_INDEX_FILE: join(s.repo.base, "index-exec") };
|
||||
const gx = (...a) => { const r = spawnSync("git", ["-C", s.repo.root, ...a], { env: execEnv, encoding: "utf8" }); assert.equal(r.status, 0, r.stderr); return r.stdout.trim(); };
|
||||
gx("read-tree", good);
|
||||
const blob = gx("rev-parse", "good:packages/x.mjs");
|
||||
gx("update-index", "--cacheinfo", `100755,${blob},packages/x.mjs`);
|
||||
gx("update-ref", "refs/heads/execbit", gx("commit-tree", gx("write-tree"), "-p", good, "-m", "execbit"));
|
||||
no(cli(s.repo, ["review", "verify-commit", "9", "execbit"]), 2, /^packages\/x.mjs: mode differs$/m);
|
||||
const kept = commitOn(s.repo, "kept", good, { "agents/rocko/.keep": "" });
|
||||
no(cli(s.repo, ["review", "verify-commit", "9", "kept"]), 2, /agents\/rocko\/.keep: deleted in the candidate, present here/);
|
||||
no(cli(s.repo, ["review", "verify-commit", "9", "other"]), 2, /packages\/x.mjs: missing\nagents\/rocko\/.keep: deleted|agents\/rocko\/.keep: deleted in the candidate, present here\npackages\/x.mjs: missing/);
|
||||
no(cli(s.repo, ["review", "verify-commit", "9", "nope"]), 2, /nope is not a commit or tree here/);
|
||||
no(cli(s.repo, ["review", "verify-commit", "11", "good"]), 2, /row 11 has no review round/);
|
||||
// A manifest candidate on row 6.
|
||||
ok(s.run(["move", "6", "in-progress", "--op", "unblock-6-0001"], "darkwing"));
|
||||
const digest = spawnSync("sha256sum", { input: "export const x = 1;\n", encoding: "utf8" }).stdout.slice(0, 64);
|
||||
const manifest = join(s.repo.base, "cand.sha256");
|
||||
writeFileSync(manifest, `${digest} packages/x.mjs\n`);
|
||||
ok(s.run(["move", "6", "in-review", "--candidate", manifest, "--op", "review-6-00001"], "darkwing"), /on #1511; request review-6-00001 requesting$/m);
|
||||
assert.match(s.posts().at(-1).body.body, new RegExp(`\`\`\`text\n${digest} packages/x.mjs\n\`\`\``));
|
||||
ok(cli(s.repo, ["review", "verify-commit", "6", "good"]), /matches the manifest candidate \(1 paths\)/);
|
||||
no(cli(s.repo, ["review", "verify-commit", "6", "changed"]), 2, /packages\/x.mjs: content differs/);
|
||||
no(cli(s.repo, ["review", "verify-commit", "6", "HEAD"]), 2, /packages\/x.mjs: missing/);
|
||||
});
|
||||
|
||||
test("semantics: v1 entries replay as before; review entries need v2", async (t) => {
|
||||
const s = await ready(t);
|
||||
const { queue } = s.m;
|
||||
ok(s.run(move9(), "darkwing"));
|
||||
const d = s.doc();
|
||||
queue.loadDoc(Buffer.from(queue.serialize(d)));
|
||||
// The same move under semantics 1 opens a round with no request.
|
||||
const mv = d.log.find((e) => e.op === "review-9-00001");
|
||||
const pre = queue.replay(d.log.slice(0, mv.rev));
|
||||
const v1 = queue.applyEntry(pre, { ...mv, semantics: 1 }, queue.resolvedFromResult("move", mv.args, mv.result));
|
||||
assert.equal(v1.state.rows.get(9).review.rounds[0].request, "none");
|
||||
assert.equal(v1.result.receipt, "ok review-9-00001 rev 3 row 9 in-progress→in-review round 1 on #1508");
|
||||
// A log written under semantics 1 with no review entries still loads.
|
||||
const old = { ...d, revision: 2, rows: queue.rowsArray(pre), log: d.log.slice(0, 3).map((e) => ({ ...e, semantics: 1 })) };
|
||||
queue.loadDoc(Buffer.from(queue.serialize(old)));
|
||||
// Relabelling the request move as semantics 1 breaks the replay.
|
||||
const forged = { ...d, log: d.log.map((e) => (e.op === mv.op ? { ...e, semantics: 1 } : e)) };
|
||||
assert.throws(() => queue.loadDoc(Buffer.from(queue.serialize(forged))), /does not replay|differs from its replay|needs semantics 2|rows do not equal/);
|
||||
const outcome = d.log.at(-1);
|
||||
assert.throws(() => queue.loadDoc(Buffer.from(queue.serialize({ ...d, log: [...d.log.slice(0, -1), { ...outcome, semantics: 1 }] }))), /review-outcome needs semantics 2/);
|
||||
assert.throws(() => queue.loadDoc(Buffer.from(queue.serialize({ ...d, log: [...d.log.slice(0, -1), { ...outcome, op: "review-9-00002.outcome" }] }))), /op must be its attempt's op plus \.outcome/);
|
||||
assert.throws(() => queue.loadDoc(Buffer.from(queue.serialize({ ...d, log: d.log.map((e, i) => (i === 1 ? { ...e, semantics: 3 } : e)) }))), /semantics 3 is not 1 to 2/);
|
||||
// An attempt takes one outcome, even one that agrees with it.
|
||||
const full = queue.replay(d.log);
|
||||
const again = { ...outcome, rev: full.revision + 1 };
|
||||
assert.throws(() => queue.applyEntry(full, again, {}), /request review-9-00001 already has its outcome/);
|
||||
// review-outcome is the one entry a done row takes.
|
||||
const at = "2026-09-27T00:00:00.000Z";
|
||||
// The request was abandoned and the row closed before the POST's answer came.
|
||||
const entry = (st, verb, op, args, by = "darkwing") => ({ rev: st.revision + 1, op, verb, args: queue.canonArgs(verb, args), by, at, semantics: 2, result: null, viewSha: null });
|
||||
const mid = queue.replay(d.log.slice(0, -1));
|
||||
assert.equal(mid.rows.get(9).review.rounds[0].attempts[0].state, "requesting");
|
||||
const gaveUp = queue.applyEntry(mid, entry(mid, "review-abandon", "abandon-9-0001", { id: 9, attempt: "review-9-00001", reason: "no answer" }, "sage"), {}).state;
|
||||
const done = new Map(gaveUp.rows);
|
||||
done.set(9, { ...gaveUp.rows.get(9), state: "done", claim: null });
|
||||
const st = { ...gaveUp, rows: done };
|
||||
const late = queue.applyEntry(st, entry(st, "review-outcome", "review-9-00001.outcome", { id: 9, attempt: "review-9-00001", outcome: "posted", status: 201, comment: 1000, detail: "created" }), {});
|
||||
const a = late.state.rows.get(9).review.rounds[0].attempts[0];
|
||||
assert.deepEqual([late.state.rows.get(9).state, a.state, a.transport.comment], ["done", "conflict", 1000]);
|
||||
const lateOk = (verb, op, args, by) => queue.applyEntry(st, entry(st, verb, op, args, by), {});
|
||||
assert.throws(() => lateOk("review-resolve", "resolve-9-00001", { id: 9, attempt: "review-9-00001", comment: 5 }), /done rows never change/);
|
||||
assert.throws(() => queue.applyEntry(mid, entry(mid, "review-outcome", "review-9-00001.outcome", { id: 9, attempt: "review-9-00001", outcome: "failed", status: 403, comment: null, detail: "x" }, "sage"), {}), /only darkwing, who made request review-9-00001, records its outcome/);
|
||||
assert.throws(() => queue.canonArgs("review-outcome", { id: 9, attempt: "review-9-00001", outcome: "failed", status: 500, comment: null, detail: "x" }), /a failed outcome's status is one of 400, 401, 403, 404, 422/);
|
||||
assert.throws(() => queue.canonArgs("review-outcome", { id: 9, attempt: "review-9-00001", outcome: "posted", status: 201, comment: null, detail: "x" }), /posted exactly when HTTP 201 returned a comment id/);
|
||||
assert.throws(() => queue.canonArgs("review-outcome", { id: 9, attempt: "review-9-00001", outcome: "posted", status: 200, comment: 4, detail: "x" }), /posted exactly when HTTP 201/);
|
||||
});
|
||||
|
||||
test("the owner records no verdict, even as a listed reviewer", async (t) => {
|
||||
const s = await ready(t);
|
||||
ok(s.run(move9(), "darkwing"));
|
||||
ok(s.run(["set", "9", "reviewers", "filbert,darkwing", "--op", "reviewers-9-self"], "sage"));
|
||||
no(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "1000", "--candidate", s.head(), "--op", "record-9-self-01"], "darkwing"), 2, /only a listed reviewer other than the owner may record a verdict on row 9/);
|
||||
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "1000", "--candidate", s.head(), "--op", "record-9-filbert"], "filbert"));
|
||||
});
|
||||
|
||||
test("a request comment over the length limit is not sent", async (t) => {
|
||||
const s = await ready(t);
|
||||
ok(s.run(["move", "6", "in-progress", "--op", "unblock-6-0001"], "darkwing"));
|
||||
const lines = Array.from({ length: 700 }, (_, i) => `${"a".repeat(64)} packages/generated/file-${String(i).padStart(4, "0")}-${"x".repeat(20)}.mjs`);
|
||||
const manifest = join(s.repo.base, "big.sha256");
|
||||
writeFileSync(manifest, `${lines.join("\n")}\n`);
|
||||
const r = no(s.run(["move", "6", "in-review", "--candidate", manifest, "--op", "review-6-00001"], "darkwing"), 1, /request review-6-00001 not sent: the comment would be longer than the limit/);
|
||||
assert.match(r.out, /request review-6-00001 failed$/m);
|
||||
assert.equal(s.round(6, 1).attempts[0].transport.detail, "pre-send: the request comment is too long");
|
||||
assert.equal(s.posts().length, 0);
|
||||
});
|
||||
|
||||
test("a late POST on a closed row leaves a conflict nothing can resolve, and resolve asks nothing", async (t) => {
|
||||
const s = await ready(t);
|
||||
const head = s.head();
|
||||
// Abandoned, approved and closed while the POST was in flight.
|
||||
const hook = (n) => {
|
||||
if (n !== "posted") return;
|
||||
s.mut({ verb: "review-abandon", op: "abandon-9-0001", args: { id: 9, attempt: "review-9-00001", reason: "gave up" }, by: "sage", yes: true });
|
||||
s.mut({ verb: "review-record", op: "record-9-filb01", args: { id: 9, verdict: "approve", comment: 88, candidate: head }, by: "filbert" });
|
||||
s.mut({ verb: "move", op: "done-9-000001", args: { id: 9, to: "done" }, by: "filbert" });
|
||||
};
|
||||
const r = s.mut({ verb: "move", op: "review-9-00001", args: { id: 9, to: "in-review", candidate: "HEAD" }, by: "darkwing" }, { hook });
|
||||
assert.equal(r.code, 3);
|
||||
const a = s.round(9, 1).attempts[0];
|
||||
assert.deepEqual([s.row(9).state, a.state, a.transport.comment], ["done", "conflict", 1000]);
|
||||
const calls = s.calls().length;
|
||||
no(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "1000", "--op", "resolve-9-00001"], "darkwing"), 2, /row 9 is done; done rows never change/);
|
||||
assert.equal(s.calls().length, calls, "a done row's resolve fetches nothing");
|
||||
assert.equal(s.posts().length, 1);
|
||||
});
|
||||
|
||||
test("a Jason-gated row reaches waiting-on-jason only on every reviewer's approval", async (t) => {
|
||||
const s = await ready(t);
|
||||
const head = s.head();
|
||||
ok(s.run(["set", "9", "gate-owner", "jason", "--op", "gate-9-jason"], "sage"));
|
||||
ok(s.run(["set", "9", "reviewers", "filbert,rocko", "--op", "reviewers-9-two"], "sage"));
|
||||
ok(s.run(move9(), "darkwing"), /posted comment 1000$/m);
|
||||
assert.deepEqual(s.round(9, 1).receipts, []);
|
||||
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "darkwing"), 2, /gate is Jason's; it goes through waiting-on-jason/);
|
||||
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /row 9 round 1 has no approval recorded by filbert, rocko$/m);
|
||||
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "sage"), 2, /no approval recorded by filbert, rocko$/m);
|
||||
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "88", "--candidate", head, "--op", "record-9-filb01"], "filbert"));
|
||||
ok(s.run(["review", "record", "9", "--verdict", "changes", "--comment", "89", "--candidate", head, "--op", "record-9-rock01"], "rocko"));
|
||||
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /no approval recorded by rocko$/m);
|
||||
// Changes go back to in-progress, as before.
|
||||
ok(s.run(["move", "9", "in-progress", "--op", "changes-9-0001"], "darkwing"));
|
||||
ok(s.run(move9("review-9-00002"), "darkwing"), /posted comment 1001$/m);
|
||||
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "91", "--candidate", head, "--op", "record-9-rock02"], "rocko"));
|
||||
// Filbert's round 1 approval doesn't carry into round 2.
|
||||
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /row 9 round 2 has no approval recorded by filbert$/m);
|
||||
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "90", "--candidate", head, "--op", "record-9-filb02"], "filbert"));
|
||||
ok(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), /in-review→waiting-on-jason$/m);
|
||||
ok(s.run(["move", "9", "done", "--op", "done-9-000001"], "jason"), /waiting-on-jason→done$/m);
|
||||
|
||||
// Reviewers removed after the round opened: waiting-on-jason refuses
|
||||
// until a privileged actor sets them.
|
||||
const s2 = await ready(t);
|
||||
ok(s2.run(["set", "9", "gate-owner", "jason", "--op", "gate-9-jason"], "sage"));
|
||||
ok(s2.run(move9(), "darkwing"));
|
||||
ok(s2.run(["set", "9", "reviewers", "none", "--op", "reviewers-9-none"], "sage"));
|
||||
no(s2.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /row 9 lists no reviewers now; a privileged actor sets them before it moves to waiting-on-jason/);
|
||||
});
|
||||
@@ -183,17 +183,20 @@ test("a retried add returns the id it first allocated, after reassignment and af
|
||||
|
||||
test("Rocko's S4 schedule: a lost result, another writer, then the retry opens no second round", (t) => {
|
||||
const repo = ready(t);
|
||||
// No reviewers, so the round posts no request; review.test.mjs covers that path.
|
||||
ok(cli(repo, ["set", "9", "reviewers", "none", "--op", "reviewers-9-none"], { by: "sage" }));
|
||||
ok(cli(repo, ["move", "6", "blocked", "--reason", "paused", "--op", "block-6-00001"], { by: "darkwing" }));
|
||||
ok(cli(repo, ["move", "9", "in-progress", "--op", "start-9-00001"], { by: "darkwing" }));
|
||||
const review = ["move", "9", "in-review", "--candidate", "HEAD", "--op", "review-9-00001"];
|
||||
cli(repo, review, { by: "darkwing" }); // result lost
|
||||
ok(cli(repo, ["note", "9", "looking now", "--op", "note-9-000001"], { by: "filbert" }));
|
||||
ok(cli(repo, review, { by: "darkwing" }), /round 1 on #1508 \(already recorded at rev 3\)/);
|
||||
ok(cli(repo, ["note", "9", "looking now", "--op", "note-9-000001"], { by: "sage" }));
|
||||
ok(cli(repo, review, { by: "darkwing" }), /round 1 on #1508 \(already recorded at rev 4\)/);
|
||||
assert.equal(row(repo, 9).review.rounds.length, 1);
|
||||
});
|
||||
|
||||
test("the review issue and the evidence round through the CLI (lead decision 23, 8.7)", (t) => {
|
||||
const repo = ready(t);
|
||||
ok(cli(repo, ["set", "9", "reviewers", "none", "--op", "reviewers-9-none"], { by: "sage" }));
|
||||
ok(cli(repo, ["move", "6", "blocked", "--reason", "paused", "--op", "block-6-00001"], { by: "darkwing" }));
|
||||
ok(cli(repo, ["set", "9", "issues", "1495,1508", "--op", "issues-9-0001"], { by: "sage" }));
|
||||
ok(cli(repo, ["move", "9", "in-progress", "--op", "start-9-00001"], { by: "darkwing" }));
|
||||
|
||||
Reference in New Issue
Block a user