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]>
163 lines
8.0 KiB
JavaScript
163 lines
8.0 KiB
JavaScript
// 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;
|
|
}
|