Builds: design 47 G §5, §111, §310 branch Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
601 lines
43 KiB
TypeScript
601 lines
43 KiB
TypeScript
// What the forge's tools refuse (guard.ts; novox/hq ADR 0266): an agent reaches the forge through these tools
|
|
// with a token minted for the forge's administrator, so the tools — not the forge — are what keeps it from
|
|
// dropping a branch's protection, writing to main, setting the merge check's status, or merging. Each route a review
|
|
// found is reproduced here against a fake forge, and each is refused with nothing sent; a merge is the seat git's
|
|
// merge-pull alone, at the head the caller names (the last test's rows).
|
|
|
|
import { test, after, beforeEach } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
import { readFileSync } from "node:fs";
|
|
|
|
import { GiteaClient } from "../dist/client.js";
|
|
import { ConfiguredToken } from "../dist/token.js";
|
|
import { getGiteaTools } from "../dist/tools/index.js";
|
|
import { judgeApiCall, judgeProtectionChange, ruleCovers, routedPath } from "../dist/guard.js";
|
|
|
|
interface Sent { method: string; path: string; body: any }
|
|
|
|
const state = {
|
|
sent: [] as Sent[],
|
|
asked: [] as string[],
|
|
statuses: [] as { context: string; state: string; description?: string; updated_at?: string }[],
|
|
// The trunk's head (novox/hq ADR 0323): what the merge tool compares the verdict's trunk commit with.
|
|
trunk: "",
|
|
// What the tools said on the bus.
|
|
emitted: [] as { type: string; body: any }[],
|
|
rules: [] as any[],
|
|
// Whether the token is a repository administrator's. The tools' account is not (novox/hq ADR 0365): the forge
|
|
// refuses it the rules' list, as gitea's reqAdmin does, and it reads a branch's protection from the branch.
|
|
admin: false,
|
|
lastCommit: "2026-01-01T00:00:00Z",
|
|
// The repository's default branch and pull request 7's base, as the forge keeps them (issue 1422 note 7).
|
|
defaultBranch: "main",
|
|
base: "main",
|
|
prState: "open",
|
|
// Branches the forge answers 404 for; "scratch" exists but no protection rule guards it.
|
|
absent: ["nope"],
|
|
// The pull request's head; the head the merge commit joined (the same, unless a test says otherwise) — the forge's
|
|
// record of the pull request keeps saying `head` either way, as it would in the race issue 381 is about; and the
|
|
// merge commit's own sha.
|
|
head: "",
|
|
landed: "",
|
|
merged: false,
|
|
mergeCommit: "",
|
|
// When set, the merge commit's parents as the forge reports them, or the status it answers them with.
|
|
parents: null as string[] | null,
|
|
commitsFail: 0,
|
|
// The sibling beside which a verdict was judged (novox/hq ADR 0382 §6): its group status at its head, and its reviews.
|
|
groupStatus: "",
|
|
siblingGroup: "",
|
|
reviews: [] as { state: string; commit_id: string; dismissed?: boolean }[],
|
|
// The forge's own mergeable of pull request 7; unset, the forge does not say.
|
|
mergeable: undefined as boolean | undefined,
|
|
};
|
|
const REVIEWED = "abc1234def5678901234567890abcdef12345678";
|
|
const SIBLING_HEAD = "bbbbbbbbbbbb5678901234567890abcdef12345678".slice(0, 40);
|
|
const TRUNK = "7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b";
|
|
const MOVED = "3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d";
|
|
// A verdict judged against a trunk commit, as both statuses say it (novox/hq ADR 0323 §3).
|
|
const judgedAt = (trunk: string, state = "success") => [
|
|
{ context: "mesh/merge-gate", state, description: `head ${REVIEWED.slice(0, 12)} merged into trunk ${trunk.slice(0, 12)} — pass: x` },
|
|
{ context: "mesh/repo-check", state, description: `head ${REVIEWED.slice(0, 12)} merged into trunk ${trunk.slice(0, 12)} — pass: y` }];
|
|
const protectedMain = () => ({ rule_name: "main", enable_push: false, enable_status_check: true,
|
|
status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"], block_admin_merge_override: true });
|
|
|
|
beforeEach(() => {
|
|
state.sent = [];
|
|
state.asked = [];
|
|
state.statuses = judgedAt(TRUNK);
|
|
state.trunk = TRUNK;
|
|
state.emitted = [];
|
|
state.rules = [protectedMain(), { rule_name: "release/*", enable_push: false }];
|
|
state.defaultBranch = "main";
|
|
state.base = "main";
|
|
state.prState = "open";
|
|
state.admin = false;
|
|
state.head = REVIEWED;
|
|
state.landed = "";
|
|
state.merged = false;
|
|
state.mergeCommit = "feedface00000000000000000000000000000000";
|
|
state.parents = null;
|
|
state.commitsFail = 0;
|
|
state.groupStatus = "";
|
|
state.siblingGroup = "";
|
|
state.reviews = [];
|
|
});
|
|
|
|
const json = (res: ServerResponse, status: number, body: unknown): void => {
|
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
res.end(body === null ? "" : JSON.stringify(body));
|
|
};
|
|
const read = (req: IncomingMessage): Promise<any> =>
|
|
new Promise((resolve) => {
|
|
let text = "";
|
|
req.on("data", (c) => (text += c));
|
|
req.on("end", () => resolve(text ? JSON.parse(text) : null));
|
|
});
|
|
|
|
const server = createServer(async (req, res) => {
|
|
const url = new URL(req.url ?? "/", "http://fake");
|
|
const path = url.pathname.replace(/^\/api\/v1/, "");
|
|
const method = req.method ?? "GET";
|
|
const body = await read(req);
|
|
state.asked.push(`${method} ${path}${url.search}`);
|
|
if (method !== "GET") state.sent.push({ method, path, body });
|
|
const repo = "/repos/novox/mesh-catalog";
|
|
if (method === "GET" && path === repo) return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: state.defaultBranch, permissions: { push: true } });
|
|
if (method === "PATCH" && path === repo) {
|
|
if (body?.default_branch) state.defaultBranch = body.default_branch;
|
|
return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: state.defaultBranch });
|
|
}
|
|
if (method === "PATCH" && path === `${repo}/pulls/7`) {
|
|
if (body?.base) state.base = body.base;
|
|
return json(res, 201, { number: 7, title: "x", state: state.prState, merged: false, head: { ref: "feat/x", sha: state.head },
|
|
base: { ref: state.base }, html_url: "http://fake/7" });
|
|
}
|
|
if (path.startsWith(`${repo}/branch_protections`) && !state.admin) {
|
|
return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" });
|
|
}
|
|
if (method === "GET" && path === `${repo}/branch_protections`) return json(res, 200, state.rules);
|
|
const branch = method === "GET" && path.startsWith(`${repo}/branches/`) ? decodeURIComponent(path.slice(`${repo}/branches/`.length)) : null;
|
|
if (branch !== null && state.absent.includes(branch)) return json(res, 404, { message: "branch does not exist" });
|
|
if (branch !== null) {
|
|
// As the forge says it to any reader of the code: the rule that covers the branch, as the forge matches it.
|
|
const rule = (state.rules as any[]).find((r) => ruleCovers(String(r.rule_name ?? r.branch_name), branch));
|
|
return json(res, 200, { name: branch, commit: { id: branch === "main" ? state.trunk : "c0ffee" }, protected: !!rule,
|
|
...(rule ? { effective_branch_protection_name: rule.rule_name ?? rule.branch_name, enable_status_check: !!rule.enable_status_check,
|
|
status_check_contexts: rule.status_check_contexts ?? [], required_approvals: rule.required_approvals ?? 0, user_can_push: !!rule.enable_push } : {}) });
|
|
}
|
|
if (method === "GET" && path === `${repo}/pulls/7/files`) {
|
|
return json(res, 200, url.searchParams.get("page") === "1" ? [{ filename: "modules/gitea/x.ts", status: "modified" }] : []);
|
|
}
|
|
if (method === "POST" && path === `${repo}/statuses/${state.head}`) {
|
|
// Kept as the forge keeps them: the newest of a context first.
|
|
state.statuses = [{ ...body, updated_at: new Date().toISOString() }, ...state.statuses.filter((s) => s.context !== body.context)];
|
|
return json(res, 201, body);
|
|
}
|
|
if (method === "GET" && path === "/user") return json(res, 200, { login: "mesh-admin" });
|
|
if (method === "GET" && path === "/repos/mesh-admin/scratch/branch_protections") return json(res, 200, []);
|
|
if (method === "GET" && /^\/repos\/[^/]+\/[^/]+\/commits$/.test(path)) {
|
|
return json(res, 200, [{ commit: { committer: { date: state.lastCommit } } }]);
|
|
}
|
|
if (method === "GET" && path === `${repo}/pulls/7`) {
|
|
return json(res, 200, { number: 7, title: "x", state: state.merged ? "closed" : state.prState, merged: state.merged,
|
|
head: { ref: "feat/x", sha: state.head }, base: { ref: state.base }, html_url: "http://fake/7",
|
|
merge_commit_sha: state.merged ? state.mergeCommit : null, ...(state.mergeable === undefined ? {} : { mergeable: state.mergeable }) });
|
|
}
|
|
if (method === "GET" && path === `${repo}/git/commits/${state.mergeCommit}`) {
|
|
if (state.commitsFail) return json(res, state.commitsFail, { message: "the forge is not well" });
|
|
const parents = state.parents ?? ["8a53532d89b20f0fe4fd0fbc814f2e86b61cbc6b", state.landed || state.head];
|
|
return json(res, 200, { sha: state.mergeCommit, parents: parents.map((sha) => ({ sha })) });
|
|
}
|
|
if (method === "GET" && path === `${repo}/commits/${state.head}/status`) {
|
|
const group = state.groupStatus ? [{ context: "mesh/delivery-group", status: state.groupStatus }] : [];
|
|
return json(res, 200, { sha: state.head, state: "success", statuses: [...state.statuses, ...group] });
|
|
}
|
|
if (method === "GET" && path === `/repos/novox/mesh-host/commits/${SIBLING_HEAD}/status`) {
|
|
return json(res, 200, { sha: SIBLING_HEAD, state: "success",
|
|
statuses: state.siblingGroup ? [{ context: "mesh/delivery-group", status: state.siblingGroup }] : [] });
|
|
}
|
|
if (method === "GET" && path === "/repos/novox/mesh-host/pulls/114/reviews") {
|
|
return json(res, 200, url.searchParams.get("page") === "1" ? state.reviews : []);
|
|
}
|
|
if (method === "GET" && path === "/repos/novox/mesh-host/issues/114/labels") return json(res, 200, []);
|
|
if (method === "GET" && path === `${repo}/pulls/404`) return json(res, 404, { message: "pull request does not exist" });
|
|
if (method === "POST" && path === `${repo}/pulls/7/merge`) {
|
|
state.merged = true;
|
|
return json(res, 200, null);
|
|
}
|
|
return json(res, method === "GET" ? 200 : 201, {});
|
|
});
|
|
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
|
|
after(() => new Promise<void>((r) => server.close(() => r())));
|
|
const { port } = server.address() as { port: number };
|
|
const client = new GiteaClient(`http://127.0.0.1:${port}`, new ConfiguredToken("t"));
|
|
const tools = getGiteaTools(client);
|
|
const tool = (name: string) => tools.find((t) => t.name === name)!;
|
|
const writes = () => state.sent.map((s) => `${s.method} ${s.path}`);
|
|
|
|
test("gitea_api refuses the forge's administration, a branch's protection and the merge check's status, and sends nothing", async () => {
|
|
for (const [method, path, body] of [
|
|
["PATCH", "/admin/users/agent", { admin: true }],
|
|
["DELETE", "/repos/novox/mesh-catalog/branch_protections/main", undefined],
|
|
["PATCH", "/repos/novox/mesh-catalog/branch_protections/main", { enable_status_check: false }],
|
|
["POST", "/repos/novox/mesh-catalog/statuses/abc1234", { context: "mesh/merge-gate", state: "success" }],
|
|
["POST", "/repos/novox/mesh-catalog/pulls/7/merge", { Do: "merge", force_merge: true }],
|
|
["PUT", "/repos/novox/mesh-catalog/collaborators/agent", { permission: "admin" }],
|
|
["POST", "/repos/novox/mesh-catalog/hooks", { type: "gitea" }],
|
|
["POST", "/repos/novox/mesh-catalog/keys", { key: "ssh-ed25519 AAAA" }],
|
|
["PATCH", "/repos/novox/mesh-catalog", { default_branch: "agent" }],
|
|
["DELETE", "/repos/novox/mesh-catalog", undefined],
|
|
["POST", "/repos/novox/mesh-catalog/transfer", { new_owner: "agent" }],
|
|
["POST", "/users/mesh-admin/tokens", { name: "mine", scopes: ["all"] }],
|
|
["PUT", "/orgs/novox/members/agent", undefined],
|
|
["PATCH", "/repos/novox/mesh-catalog/issues/1/../../branch_protections/main", { enable_push: true }],
|
|
["PATCH", "/repos/novox/mesh-catalog/issues/1/%2e%2e/%2e%2e/branch_protections/main", { enable_push: true }],
|
|
] as const) {
|
|
await assert.rejects(tool("gitea_api").run({ path, method, ...(body ? { body } : {}) }),
|
|
/not a write gitea_api makes|not taken|administration/, `${method} ${path}`);
|
|
}
|
|
await assert.rejects(tool("gitea_api").run({ path: "/admin/users" }), /administration/);
|
|
assert.deepEqual(writes(), []);
|
|
});
|
|
|
|
test("gitea_api refuses a file written to the default branch or one a rule guards, and writes one on a branch of its own", async () => {
|
|
const put = (body: Record<string, unknown>) =>
|
|
tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/contents/modules/sudo/module.json", method: "PUT", body });
|
|
await assert.rejects(put({ content: "e30=", sha: "x" }), /default branch/);
|
|
await assert.rejects(put({ content: "e30=", sha: "x", branch: "main" }), /default branch/);
|
|
state.rules.push({ rule_name: "release/*" });
|
|
await assert.rejects(put({ content: "e30=", sha: "x", branch: "release/1" }), /protection rule release\/\*/);
|
|
assert.deepEqual(writes(), []);
|
|
await put({ content: "e30=", sha: "x", branch: "main", new_branch: "feat/sudo-docs" });
|
|
assert.deepEqual(writes(), ["PUT /repos/novox/mesh-catalog/contents/modules/sudo/module.json"]);
|
|
});
|
|
|
|
test("a protection rule covers a branch as the forge decides it: classes, alternatives, a plain name in any case (issue 539)", async () => {
|
|
// One table with the forge bundle (cmd/forge/rules.go): `forge` is the forge's answer; `guard` is where this
|
|
// guard deliberately covers more — a rule Go's regexp reads otherwise than JavaScript's — and never less.
|
|
const cases = JSON.parse(readFileSync(new URL("./rule-cases.json", import.meta.url), "utf8")) as
|
|
Array<{ rule: string; branch: string; forge: boolean; guard?: boolean }>;
|
|
assert.ok(cases.length >= 100);
|
|
for (const c of cases) {
|
|
assert.ok(c.guard === undefined || (c.guard && !c.forge), `${c.rule}: the guard may only cover more than the forge`);
|
|
assert.equal(ruleCovers(c.rule, c.branch), c.guard ?? c.forge, `${c.rule} covers ${c.branch}`);
|
|
}
|
|
const put = (branch: string) => tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/contents/README.md",
|
|
method: "PUT", body: { content: "e30=", sha: "x", branch } });
|
|
state.rules.push({ rule_name: "release-[ab]" }, { rule_name: "{stable,lts}/*" });
|
|
await assert.rejects(put("release-a"), /protection rule release-\[ab\]/);
|
|
await assert.rejects(put("lts/2"), /protection rule \{stable,lts\}\/\*/);
|
|
await assert.rejects(put("MAIN"), /protection rule main/);
|
|
assert.deepEqual(writes(), []);
|
|
});
|
|
|
|
test("gitea_api still writes what an author writes", async () => {
|
|
state.admin = true; // reads the rules' list, a repository administrator's (novox/hq ADR 0365)
|
|
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/issues/3/comments", method: "POST", body: { body: "hi" } });
|
|
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/pulls/7/reviews", method: "POST", body: { event: "COMMENT" } });
|
|
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/branch_protections" });
|
|
assert.deepEqual(writes(), ["POST /repos/novox/mesh-catalog/issues/3/comments", "POST /repos/novox/mesh-catalog/pulls/7/reviews"]);
|
|
});
|
|
|
|
// **The forge requires no status to merge** (novox/hq ADR 0534 §5): the tool writes no required status and takes no
|
|
// status argument; taking a required status away is no weakening it refuses, and a push or an administrator's merge
|
|
// past the rule still is.
|
|
test("gitea_branch_protection_set writes no required status, and refuses a push or an administrator's override", async () => {
|
|
state.admin = true; // a repository administrator's token: the tools' account is refused the rules by the forge
|
|
const set = (args: Record<string, unknown>) =>
|
|
tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-catalog", branch: "main", ...args });
|
|
assert.equal((tool("gitea_branch_protection_set").input as Record<string, unknown>).status_checks, undefined,
|
|
"the tool takes no status argument");
|
|
await assert.rejects(set({ push: true }), /push to the branch directly/);
|
|
await assert.rejects(set({ block_admin_override: false }), /merge past/);
|
|
assert.deepEqual(writes(), []);
|
|
await set({ status_checks: "mesh/check" }); // an argument it no longer takes is not read
|
|
assert.deepEqual(writes(), ["PATCH /repos/novox/mesh-catalog/branch_protections/main"]);
|
|
const sent = state.sent[0].body;
|
|
assert.equal(sent.enable_status_check, false);
|
|
assert.deepEqual(sent.status_check_contexts, []);
|
|
assert.equal(sent.enable_push, false);
|
|
});
|
|
|
|
|
|
test("gitea_delete_repo deletes only the tools' own user's repository, unguarded, untouched for 30 days, named in full", async () => {
|
|
state.admin = true; // reads the rules' list, a repository administrator's (novox/hq ADR 0365)
|
|
const del = (owner: string, name: string, confirm: unknown) => tool("gitea_delete_repo").run({ owner, name, confirm });
|
|
await assert.rejects(del("novox", "mesh-catalog", true), /confirm must be the repository's full name/);
|
|
await assert.rejects(del("novox", "mesh-catalog", "novox/mesh-catalog"), /not a repository of the tools' own user/);
|
|
state.lastCommit = new Date().toISOString();
|
|
await assert.rejects(del("mesh-admin", "scratch", "mesh-admin/scratch"), /within 30 days/);
|
|
assert.deepEqual(writes(), []);
|
|
state.lastCommit = "2026-01-01T00:00:00Z";
|
|
await del("mesh-admin", "scratch", "mesh-admin/scratch");
|
|
assert.deepEqual(writes(), ["DELETE /repos/mesh-admin/scratch"]);
|
|
});
|
|
|
|
test("gitea_api sends the path it judged, and refuses moving a pull request's base", async () => {
|
|
await tool("gitea_api").run({ path: "/api/v1/repos/novox/mesh-catalog/issues/3/comments/?page=1", method: "POST", body: { body: "hi" } });
|
|
assert.deepEqual(writes(), ["POST /repos/novox/mesh-catalog/issues/3/comments"]);
|
|
await assert.rejects(tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/pulls/7", method: "PATCH",
|
|
body: { base: "unguarded" } }), /changing a pull request's base/);
|
|
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/pulls/7", method: "PATCH", body: { title: "x" } });
|
|
assert.equal(writes().length, 2);
|
|
});
|
|
|
|
test("a rule named for a branch is not made weaker than the glob rule that guards it now", async () => {
|
|
state.admin = true; // reads the rules' list, a repository administrator's (novox/hq ADR 0365)
|
|
state.rules.push({ rule_name: "release/*", enable_push: false, enable_status_check: true,
|
|
status_check_contexts: ["mesh/merge-gate"], block_admin_merge_override: true });
|
|
const set = (args: Record<string, unknown>) =>
|
|
tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-catalog", branch: "release/1", ...args });
|
|
await assert.rejects(set({}), /merge past/);
|
|
assert.deepEqual(writes(), []);
|
|
await set({ block_admin_override: true }); // the glob's required status is no longer asked (ADR 0534 §5)
|
|
assert.deepEqual(writes(), ["POST /repos/novox/mesh-catalog/branch_protections"]);
|
|
});
|
|
|
|
test("a repository owner or name is never another path", async () => {
|
|
await assert.rejects(tool("gitea_update_pull_request").run({ owner: "novox", repo: "mesh-catalog/branch_protections/main?",
|
|
number: 1, title: "x" }), /not a repository name/);
|
|
await assert.rejects(tool("gitea_get_issue").run({ owner: "..", repo: "admin", number: 1 }), /not a repository owner/);
|
|
assert.deepEqual(writes(), []);
|
|
});
|
|
|
|
test("the judges, without a forge", () => {
|
|
assert.equal(routedPath("/api/v1/repos/a/b/issues?x=1"), "/repos/a/b/issues");
|
|
assert.throws(() => routedPath("/repos/a/b/../c"), /dot segment/);
|
|
assert.ok("allowed" in judgeApiCall("GET", "/repos/a/b/branch_protections", undefined));
|
|
assert.ok("refused" in judgeApiCall("POST", "/repos/a/b/branch_protections", {}));
|
|
assert.ok("contents" in judgeApiCall("POST", "/repos/a/b/contents", { new_branch: "x" }));
|
|
assert.ok(ruleCovers("release/**", "release/a/b") && ruleCovers("feat/*", "feat/a") && !ruleCovers("feat/*", "feat/a/b"));
|
|
assert.equal(judgeProtectionChange(null, {}), null);
|
|
assert.equal(judgeProtectionChange(protectedMain(), {}), null, "taking its required statuses off is no weakening (ADR 0534 §5)");
|
|
});
|
|
|
|
test("main's protection is never weakened in its push or its merge whitelist (issue 948, ADR 0398 §2)", () => {
|
|
const strong = () => ({ ...protectedMain(), enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"] });
|
|
assert.equal(judgeProtectionChange(strong(), {}, [], "main"), null);
|
|
assert.equal(judgeProtectionChange(strong(), { mergeWhitelist: ["mesh-tools"] }, [], "main"), null);
|
|
assert.match(judgeProtectionChange(strong(), { mergeWhitelist: [] }, [], "main") ?? "", /merge whitelist/);
|
|
assert.match(judgeProtectionChange(strong(), { mergeWhitelist: null }, [], "main") ?? "", /merge whitelist/);
|
|
assert.match(judgeProtectionChange(strong(), { mergeWhitelist: ["mesh-tools", "agent-ace"] }, [], "main") ?? "", /widens/);
|
|
assert.match(judgeProtectionChange(strong(), { mergeWhitelist: ["agent-ace"] }, [], "main") ?? "", /widens/);
|
|
assert.match(judgeProtectionChange(strong(), { push: true }, [], "main") ?? "", /push/);
|
|
// Even a rule that lets a person push already, or has no rule yet, is not given a push through a tool.
|
|
assert.match(judgeProtectionChange({ ...strong(), enable_push: true }, { push: true }, [], "main") ?? "", /push/);
|
|
assert.match(judgeProtectionChange(null, { push: true }, [], "main") ?? "", /push/);
|
|
assert.match(judgeProtectionChange(null, { mergeWhitelist: ["agent-ace"] }, [], "main") ?? "", /widens/);
|
|
// Another branch is judged as before.
|
|
assert.equal(judgeProtectionChange(null, { push: true }, [], "feat/x"), null);
|
|
});
|
|
|
|
test("gitea_api never acts as another user: sudo, in any case, and any query key it does not send are refused", async () => {
|
|
for (const path of ["/user?sudo=jschoubben", "/user?SUDO=jschoubben", "/repos/novox/mesh-catalog/issues?state=open&Sudo=jschoubben",
|
|
"/repos/novox/mesh-catalog/issues?token=abc", "/repos/novox/mesh-catalog/issues?access_token=abc"]) {
|
|
await assert.rejects(tool("gitea_api").run({ path }), /query key/, path);
|
|
}
|
|
await assert.rejects(tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/issues/3/comments?sudo=jschoubben",
|
|
method: "POST", body: { body: "as the operator" } }), /query key sudo/);
|
|
assert.deepEqual(state.asked, []);
|
|
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/issues?state=open&limit=5" });
|
|
assert.deepEqual(state.asked, ["GET /repos/novox/mesh-catalog/issues?state=open&limit=5"]);
|
|
});
|
|
|
|
test("gitea_api never approves a pull request nor requests changes: those are the operator's", async () => {
|
|
for (const [path, event] of [["/repos/novox/mesh-catalog/pulls/7/reviews", "APPROVE"],
|
|
["/repos/novox/mesh-catalog/pulls/7/reviews", "approve"], ["/repos/novox/mesh-catalog/pulls/7/reviews", "REQUEST_CHANGES"],
|
|
["/repos/novox/mesh-catalog/pulls/7/reviews/5", "APPROVE"]]) {
|
|
await assert.rejects(tool("gitea_api").run({ path, method: "POST", body: { event, body: "lgtm" } }),
|
|
/operator's at the forge/, `${path} ${event}`);
|
|
}
|
|
assert.deepEqual(writes(), []);
|
|
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/pulls/7/reviews", method: "POST", body: { event: "COMMENT", body: "a note" } });
|
|
assert.deepEqual(writes(), ["POST /repos/novox/mesh-catalog/pulls/7/reviews"]);
|
|
});
|
|
|
|
test("no tool's caller text becomes another path or another user: `..` and `?sudo=` are refused before the forge", async () => {
|
|
const repo = { owner: "novox", repo: "mesh-catalog" };
|
|
const cases: Array<[string, Record<string, unknown>]> = [
|
|
["gitea_delete_branch", { ...repo, branch: ".." }],
|
|
["gitea_delete_branch", { ...repo, branch: "feat/../.." }],
|
|
["gitea_delete_branch", { ...repo, branch: "x?sudo=jschoubben" }],
|
|
["gitea_delete_branch", { ...repo, branch: "%2e%2e" }],
|
|
["gitea_get_file", { ...repo, path: "../../../../version" }],
|
|
["gitea_get_file", { ...repo, path: "README.md?sudo=jschoubben" }],
|
|
["gitea_commit_statuses", { ...repo, sha: ".." }],
|
|
["gitea_commit_statuses", { ...repo, sha: "main?sudo=jschoubben" }],
|
|
["gitea_branch_protection_set", { ...repo, branch: "..", status_checks: "mesh/merge-gate" }],
|
|
["gitea_branch_protection_set", { ...repo, branch: "main?sudo=jschoubben", status_checks: "mesh/merge-gate" }],
|
|
["gitea_contains", { ...repo, commit: "..", in: "abc1234" }],
|
|
];
|
|
for (const [name, args] of cases) {
|
|
state.asked = [];
|
|
await assert.rejects(tool(name).run(args), (err: unknown) => err instanceof Error, `${name} ${JSON.stringify(args)}`);
|
|
// Nothing that is not a read of the repository's own rules reached the forge.
|
|
assert.deepEqual(state.asked.filter((a) => !a.startsWith("GET /repos/novox/mesh-catalog/branch_protections")), [],
|
|
`${name} ${JSON.stringify(args)} reached the forge`);
|
|
}
|
|
// What is ordinary still goes: a branch with a slash, a file in a directory.
|
|
state.asked = [];
|
|
await tool("gitea_delete_branch").run({ ...repo, branch: "feat/x" });
|
|
await tool("gitea_get_file").run({ ...repo, path: "modules/gitea/module.json" }).catch(() => undefined);
|
|
assert.deepEqual(state.asked, ["DELETE /repos/novox/mesh-catalog/branches/feat/x",
|
|
"GET /repos/novox/mesh-catalog/contents/modules/gitea", // the file's size first (issue 1577)
|
|
"GET /repos/novox/mesh-catalog/contents/modules/gitea/module.json"]);
|
|
});
|
|
|
|
|
|
// Issue 1422 note 7: the two settings gitea_api refuses (ADR 0266) have named tools, held to the same guard as
|
|
// gitea_branch_protection_set: the tools' account, a stated reason, a branch that exists and that a rule guards.
|
|
test("gitea_default_branch_set answers the default before and after, and refuses a branch that is missing, unguarded or unexplained", async () => {
|
|
const set = (args: Record<string, unknown>) =>
|
|
tool("gitea_default_branch_set").run({ owner: "novox", repo: "mesh-catalog", why: "the trunk was recorded as unknown", ...args });
|
|
await assert.rejects(set({ branch: "nope" }), /no branch nope/);
|
|
await assert.rejects(set({ branch: "scratch" }), /no protection rule/);
|
|
await assert.rejects(set({ branch: "release/1", why: " " }), /why/);
|
|
await assert.rejects(set({ branch: ".." }), /dot segment|not a branch/);
|
|
assert.deepEqual(writes(), []);
|
|
assert.deepEqual(await set({ branch: "release/1" }), { branch: "release/1", before: "main", after: "release/1", changed: true,
|
|
why: "the trunk was recorded as unknown" });
|
|
assert.deepEqual(writes(), ["PATCH /repos/novox/mesh-catalog"]);
|
|
assert.deepEqual(state.sent[0].body, { default_branch: "release/1" });
|
|
assert.equal(state.defaultBranch, "release/1");
|
|
state.sent = [];
|
|
assert.equal(((await set({ branch: "release/1" })) as any).changed, false, "already the default: nothing is sent");
|
|
assert.deepEqual(writes(), []);
|
|
});
|
|
|
|
test("gitea_pull_base_set answers the base before and after, and refuses a base that is missing, a closed pull request or no reason", async () => {
|
|
const set = (args: Record<string, unknown>) =>
|
|
tool("gitea_pull_base_set").run({ owner: "novox", repo: "mesh-catalog", number: 7, why: "it was opened against the wrong trunk", ...args });
|
|
await assert.rejects(set({ base: "nope" }), /no branch nope/);
|
|
await assert.rejects(set({ base: "release/1", why: "" }), /why/);
|
|
await assert.rejects(set({ base: "release/1", number: 0 }), /pull request's number/);
|
|
state.prState = "closed";
|
|
await assert.rejects(set({ base: "release/1" }), /not open/);
|
|
state.prState = "open";
|
|
assert.deepEqual(writes(), []);
|
|
assert.deepEqual(await set({ base: "release/1" }), { number: 7, before: "main", after: "release/1", changed: true,
|
|
why: "it was opened against the wrong trunk" });
|
|
assert.deepEqual(writes(), ["PATCH /repos/novox/mesh-catalog/pulls/7"]);
|
|
assert.deepEqual(state.sent[0].body, { base: "release/1" });
|
|
assert.equal(state.base, "release/1");
|
|
});
|
|
|
|
test("the two new tools are named for what mesh_search is asked, and gitea_api still refuses both settings", () => {
|
|
assert.match(tool("gitea_default_branch_set").description, /default branch/i);
|
|
assert.match(tool("gitea_pull_base_set").description, /pull request.s base/i);
|
|
assert.ok("refused" in judgeApiCall("PATCH", "/repos/a/b", { default_branch: "x" }));
|
|
assert.ok("refused" in judgeApiCall("PATCH", "/repos/a/b/pulls/7", { base: "x" }));
|
|
});
|
|
|
|
// **The seat git's verbs judge their caller, and merge-pull judges the Approve's head and no check** (design 47 *The seat
|
|
// git*, ADR 0661 §3): a caller the trusted list does not name is refused before the forge is asked; merge-pull merges the
|
|
// head it is named into a base that takes no push, whatever its statuses say, and an id said again after the merge is
|
|
// answered performed.
|
|
test("the seat git's verbs judge their caller, and merge-pull merges the Approve's head and judges no check", async () => {
|
|
const { seatTools, gitCallers } = await import("../dist/seat.js");
|
|
const { runAs } = await import("@novox/mesh-sdk/callers");
|
|
const bridge = "ace.mesh-delivery-forge-bridge";
|
|
const list = JSON.stringify({ publish: ["mesh-build-go"], "merge-pull": ["mesh-delivery-forge-bridge"],
|
|
pull: ["mesh-delivery-forge-bridge", "mesh-delivery-gate"], status: ["mesh-delivery-forge-bridge"] });
|
|
assert.deepEqual(Object.keys(gitCallers(list)).sort(), ["merge-pull", "pull", "status"], "only the seat git's lines are its");
|
|
assert.deepEqual(gitCallers(undefined), {}, "unset, no one may call");
|
|
// The head's one writer of its statuses (gate.ts), over a forge whose first write is still in flight when the next is asked.
|
|
const { GateWriter } = await import("../dist/gate.js");
|
|
const landed: string[] = [];
|
|
let inFlight: Promise<void> | null = null, land = () => {};
|
|
const slow = {
|
|
headChecks: async () => ({}),
|
|
setCommitStatus: async (_o: string, _r: string, _sha: string, s: { state: string; description: string }) => {
|
|
if (inFlight) { const w = inFlight; inFlight = null; await w; }
|
|
landed.push(`${s.state} ${s.description}`);
|
|
},
|
|
};
|
|
const gates = new GateWriter(slow);
|
|
const seat = seatTools(Object.assign(Object.create(client), { setCommitStatus: slow.setCommitStatus }),
|
|
{ run: async () => ({ stdout: "", code: 0 }), container: "gitea", callers: async () => list, statuses: gates });
|
|
const call = (verb: string, args: Record<string, unknown>, caller = bridge) =>
|
|
runAs({ "mesh/caller": caller }, args, () => seat.find((t: any) => t.name === `git.${verb}`)!.run(args)) as Promise<any>;
|
|
const merge = (extra: Record<string, unknown> = {}, caller?: string) =>
|
|
call("merge-pull", { id: "act-1", repository: "novox/mesh-catalog", number: 7, head: REVIEWED, ...extra }, caller);
|
|
const rows: { name: string; set: () => void; caller?: string; extra?: Record<string, unknown>; want: RegExp | "merged" | "held"; loud?: boolean }[] = [
|
|
{ name: "a caller not on the list", set: () => {}, caller: "ace.mesh-delivery-checker", want: /refused for ace.mesh-delivery-checker/ },
|
|
{ name: "an unproven caller", set: () => {}, caller: "", want: /no caller the tool runner proved/ },
|
|
{ name: "a head that moved", set: () => { state.head = MOVED; }, want: /its head is 3c4d5e6f7a8b, not the abc1234def56/ },
|
|
{ name: "a base no rule guards", set: () => { state.rules = []; }, want: /guarded by no protection rule/ },
|
|
{ name: "a base that takes a push", set: () => { state.rules = [{ rule_name: "main", enable_push: true }]; }, want: /guarded by no protection rule/ },
|
|
{ name: "a failed check", set: () => { state.statuses = [{ context: "mesh/check", state: "failure", description: "fail" }]; }, want: "merged" },
|
|
{ name: "no status at all", set: () => { state.statuses = []; }, want: "merged" },
|
|
{ name: "a check of another trunk", set: () => { state.statuses = judgedAt(MOVED); }, want: "merged" },
|
|
{ name: "the id said again after the merge", set: () => { state.merged = true; }, want: "held" },
|
|
{ name: "a pull request merged at another head", set: () => { state.merged = true; state.head = MOVED; }, want: /merged at 3c4d5e6f7a8b, not the abc1234def56/ },
|
|
{ name: "a closed pull request", set: () => { state.prState = "closed"; }, want: /it is closed/ },
|
|
{ name: "a fast-forward", set: () => { state.parents = [REVIEWED]; }, want: "merged" },
|
|
{ name: "the merge read back fails", set: () => { state.commitsFail = 502; }, want: "merged" },
|
|
{ name: "the forge merged another head", set: () => { state.landed = MOVED; }, want: /WAS merged .*the forge merged 3c4d5e6f7a8b, not the abc1234def56/, loud: true },
|
|
];
|
|
for (const r of rows) {
|
|
state.merged = false; state.head = REVIEWED; state.statuses = judgedAt(TRUNK); state.sent = [];
|
|
state.rules = [protectedMain()]; state.prState = "open"; state.parents = null; state.commitsFail = 0; state.landed = "";
|
|
r.set();
|
|
const got = await merge(r.extra, r.caller);
|
|
if (r.want === "merged") {
|
|
assert.equal(got.performed, true, `${r.name}: ${JSON.stringify(got)}`);
|
|
assert.equal(got["merge-commit"], state.mergeCommit, r.name);
|
|
assert.equal(state.sent.find((s) => s.path.endsWith("/pulls/7/merge"))?.body.head_commit_id, REVIEWED, r.name);
|
|
} else if (r.want === "held") {
|
|
assert.deepEqual([got.performed, got.held], [true, true], r.name);
|
|
assert.deepEqual(writes(), [], `${r.name}: nothing merged again`);
|
|
} else {
|
|
assert.match(String(got.refused ?? got.error), r.want, r.name);
|
|
assert.deepEqual(writes(), r.loud ? ["POST /repos/novox/mesh-catalog/pulls/7/merge"] : [], `${r.name}: what was merged`);
|
|
}
|
|
}
|
|
// A read: an open pull request answers what the forge would merge now; a forge that fails is the kind failed.
|
|
state.merged = false; state.head = REVIEWED; state.rules = [protectedMain()];
|
|
const pull = await call("pull", { repository: "novox/mesh-catalog", number: 7 });
|
|
assert.equal(pull.pull, undefined, "answered whole or not at all");
|
|
assert.equal(pull.error?.startsWith("the forge did not say whether novox/mesh-catalog#7 merges now"), true, JSON.stringify(pull));
|
|
assert.equal(pull["error-kind"], "failed", "a read the forge could not make answers the verb's error, never a guess");
|
|
state.mergeable = true;
|
|
const open = await call("pull", { repository: "novox/mesh-catalog", number: 7 });
|
|
state.mergeable = undefined;
|
|
assert.deepEqual([open.pull?.head, open.pull?.state, open.merging?.protected, open.merging?.required],
|
|
[REVIEWED, "open", true, ["mesh/merge-gate", "mesh/repo-check"]], JSON.stringify(open));
|
|
assert.equal("body" in (open.pull ?? {}), false, "never its description");
|
|
// Two writes of one head out of order (issue 456): a recheck's pending still in flight when the verdict is set through the
|
|
// seat; the verdict takes its turn after it and is what the head carries.
|
|
inFlight = new Promise<void>((r) => (land = r));
|
|
const recheck = gates.pending("novox", "mesh-catalog", REVIEWED);
|
|
const verdict = call("status", { id: "act-s", repository: "novox/mesh-catalog", commit: REVIEWED, state: "success",
|
|
context: "mesh/merge-gate", description: "passed" });
|
|
await new Promise((r) => setImmediate(r));
|
|
land();
|
|
await recheck;
|
|
assert.equal((await verdict).performed, true);
|
|
assert.equal(landed.at(-1), "success passed", `the verdict lands last: ${JSON.stringify(landed)}`);
|
|
// A pull request the forge does not have is the kind not-found, so a caller never reads it out of the words.
|
|
const gone = await call("pull", { repository: "novox/mesh-catalog", number: 404 });
|
|
assert.deepEqual([gone.pull, gone["error-kind"]], [undefined, "not-found"], JSON.stringify(gone));
|
|
});
|
|
|
|
// Each verb of the seat git answers the contract's shape from what the forge gives, and an error naming why when the forge
|
|
// does not (design 47 §310, §312, §317): an empty list or an absent file only when the forge said so, a forge that failed
|
|
// the kind failed, a 404 the kind not-found, and an act whose id the pull request or the note already holds is held.
|
|
test("every read and act of the seat git answers its contract, and an error is an error, never an empty answer", async () => {
|
|
const { seatTools } = await import("../dist/seat.js");
|
|
const { runAs } = await import("@novox/mesh-sdk/callers");
|
|
const SHA = "a".repeat(40), OTHER = "b".repeat(40);
|
|
const gone = () => { throw new Error("Gitea API GET /repos/novox/app/pulls/9: 404 Not Found"); };
|
|
const down = () => { throw new Error("Gitea API GET /repos/novox/app/x: 502 Bad Gateway"); };
|
|
const pr = { number: 9, title: "t", state: "open", merged: false, head: "feat/x", head_sha: SHA, base: "main", user: "jochen", html_url: "http://f/9", mergeable: true };
|
|
let comments: { id: number; body: string }[] = [], noteShown = "", added = 0, noteWritten = 0;
|
|
const forge: Record<string, any> = {
|
|
getPullRequest: async (_o: string, _r: string, n: number) => (n === 9 ? pr : gone()),
|
|
pullOfCommit: async () => ({ ...pr, state: "closed", merged: true, merge_commit_sha: OTHER, merged_at: "2026-11-11T10:00:00Z" }),
|
|
appliedProtection: async () => ({ rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/check"] }),
|
|
getRepo: async () => ({ writes: true }),
|
|
openPulls: async () => [{ owner: "novox", repo: "app", number: 9, base: "main", head: "feat/x", head_sha: SHA, required: ["mesh/check"], checks: {} }],
|
|
tags: async () => ["go/v0.1.1", "go/v0.1.0", "v1"],
|
|
commitTrees: async () => ({ root: SHA, trees: { "": SHA } }),
|
|
getFile: async (_o: string, _r: string, path: string) => (path === "gone" ? (() => { throw new Error("Gitea API GET /repos/novox/app/contents/gone: 404 Not Found"); })() : path === "down" ? down() : { path, sha: OTHER, size: 3, content: "abc" }),
|
|
compare: async () => ({ total_commits: 2, commits: [{ sha: OTHER }, { sha: SHA }] }),
|
|
combinedStatus: async () => ({ sha: SHA, state: "success", statuses: [{ context: "mesh/check", status: "success", description: "ok" }] }),
|
|
listComments: async () => comments,
|
|
addComment: async (_o: string, _r: string, _n: number, body: string) => { added++; comments.push({ id: added, body }); },
|
|
editComment: async () => {},
|
|
};
|
|
const run = async (_f: string, args: string[]) => args.includes("show") || args.includes("--ref") && !args.includes("append")
|
|
? { stdout: noteShown, code: noteShown ? 0 : 1 } : (noteWritten++, { stdout: "", code: 0 });
|
|
const seat = seatTools(forge as any, { run: run as any, container: "gitea", callers: async () => JSON.stringify({}), statuses: { status: async () => {} } });
|
|
const everyone = JSON.stringify(Object.fromEntries(seat.map((t: any) => [t.name.slice(4), ["*"]])));
|
|
const call = (verb: string, args: Record<string, unknown>, over: Record<string, unknown> = {}) => {
|
|
const t = seat.find((x: any) => x.name === `git.${verb}`)!;
|
|
const wrapped = seatTools(Object.assign(Object.create(forge), over) as any, { run: run as any, container: "gitea", callers: async () => everyone, statuses: { status: async () => {} } });
|
|
return runAs({ "mesh/caller": "ace.x" }, args, () => wrapped.find((x: any) => x.name === t.name)!.run(args)) as Promise<any>;
|
|
};
|
|
const rows: [string, string, Record<string, unknown>, Record<string, unknown>, (a: any) => unknown, unknown][] = [
|
|
["pull", "by number", { repository: "novox/app", number: 9 }, {}, (a) => [a.pull.head, a.pull.state, a.merging.mergeable, a.merging.required, a.merging.writes], [SHA, "open", true, ["mesh/check"], true]],
|
|
["pull", "by merge commit", { repository: "novox/app", "merge-commit": OTHER }, {}, (a) => [a.pull.state, a.pull["merge-commit"], a.merging], ["merged", OTHER, undefined]],
|
|
["pull", "unknown", { repository: "novox/app", number: 404 }, {}, (a) => [a.pull, a["error-kind"]], [undefined, "not-found"]],
|
|
["pull", "forge down", { repository: "novox/app", number: 9 }, { getPullRequest: down }, (a) => [a.pull, a["error-kind"]], [undefined, "failed"]],
|
|
["branch", "a branch a rule guards", { repository: "novox/app", branch: "main" }, { branchHead: async () => SHA, appliedProtection: async () => ({ rule_name: "main", enable_push: false }) }, (a) => [a.head, a.protected], [SHA, true]],
|
|
["branch", "a branch no rule guards", { repository: "novox/app", branch: "scratch" }, { branchHead: async () => SHA, appliedProtection: async () => null }, (a) => [a.head, a.protected], [SHA, false]],
|
|
["branch", "a rule that takes a push", { repository: "novox/app", branch: "main" }, { branchHead: async () => SHA, appliedProtection: async () => ({ rule_name: "main", enable_push: true }) }, (a) => [a.protected], [false]],
|
|
["branch", "no such branch", { repository: "novox/app", branch: "nope" }, { branchHead: gone }, (a) => [a.head, a["error-kind"]], [undefined, "not-found"]],
|
|
["open-pulls", "one", {}, {}, (a) => [a.pulls.length, a.pulls[0].required], [1, ["mesh/check"]]],
|
|
["open-pulls", "forge down", {}, { openPulls: down }, (a) => [a.pulls, a["error-kind"]], [undefined, "failed"]],
|
|
["tags", "a page", { repository: "novox/app", prefix: "go/" }, {}, (a) => [a.tags, a.page, a.bound, a.more], [["go/v0.1.1", "go/v0.1.0"], 1, 50, undefined]],
|
|
["tags", "forge down", { repository: "novox/app" }, { tags: down }, (a) => [a.tags, a["error-kind"]], [undefined, "failed"]],
|
|
["trees", "root", { repository: "novox/app", commit: SHA }, {}, (a) => [a.commit, a.root], [SHA, SHA]],
|
|
["trees", "forge down", { repository: "novox/app", commit: SHA }, { commitTrees: down }, (a) => [a.root, a["error-kind"]], [undefined, "failed"]],
|
|
["file", "present", { repository: "novox/app", path: "a.txt" }, {}, (a) => [a.content, a.absent], ["abc", undefined]],
|
|
["file", "the forge said absent", { repository: "novox/app", path: "gone" }, {}, (a) => [a.absent, a.error], [true, undefined]],
|
|
["file", "forge down is no absent file", { repository: "novox/app", path: "down" }, {}, (a) => [a.absent, a["error-kind"]], [undefined, "failed"]],
|
|
["contains", "ahead", { repository: "novox/app", commit: SHA, in: OTHER }, {}, (a) => [a.contains, a.ahead], [false, 2]],
|
|
["contains", "forge down", { repository: "novox/app", commit: SHA, in: OTHER }, { compare: down }, (a) => [a.contains, a["error-kind"]], [undefined, "failed"]],
|
|
["statuses", "newest of each", { repository: "novox/app", commit: SHA }, {}, (a) => [a.combined, a.statuses.map((s: any) => s.context)], ["success", ["mesh/check"]]],
|
|
["statuses", "forge down", { repository: "novox/app", commit: SHA }, { combinedStatus: down }, (a) => [a.statuses, a["error-kind"]], [undefined, "failed"]],
|
|
["status", "refused state", { id: "s1", repository: "novox/app", commit: SHA, context: "c", state: "bogus" }, {}, (a) => [a.performed, a["error-kind"]], [undefined, "refused"]],
|
|
["comment", "first time", { id: "c1", repository: "novox/app", number: 9, text: "hi" }, {}, (a) => [a.performed, a.held, added], [true, undefined, 1]],
|
|
["comment", "forge down", { id: "c2", repository: "novox/app", number: 9, text: "hi" }, { listComments: down }, (a) => [a.performed, a["error-kind"]], [undefined, "failed"]],
|
|
["view", "first time", { id: "v1", repository: "novox/app", number: 9, text: "state" }, {}, (a) => [a.performed, a.held], [true, undefined]],
|
|
["note", "a line", { id: "n1", repository: "novox/app", commit: SHA, line: "planned" }, {}, (a) => [a.performed, a.held, noteWritten], [true, undefined, 1]],
|
|
];
|
|
for (const [verb, name, args, over, pick, want] of rows) {
|
|
assert.deepEqual(pick(await call(verb, args, over)), want, `${verb}: ${name}`);
|
|
}
|
|
// The same comment id said again is held, and nothing is written again; so is a note line whose id the note holds.
|
|
const before = added;
|
|
const again = await call("comment", { id: "c1", repository: "novox/app", number: 9, text: "hi" });
|
|
assert.deepEqual([again.performed, again.held, added], [true, true, before]);
|
|
const view = await call("view", { id: "v1", repository: "novox/app", number: 9, text: "state" });
|
|
assert.deepEqual([view.performed, view.held], [true, true]);
|
|
noteShown = "planned [n1]"; noteWritten = 0;
|
|
const note = await call("note", { id: "n1", repository: "novox/app", commit: SHA, line: "worded differently" });
|
|
assert.deepEqual([note.performed, note.held, noteWritten], [true, true, 0]);
|
|
});
|