gitea: only the forge bridge writes mesh/check, through a tool of its own (issue 1301, S4)
Once main requires mesh/check, a hand that set it could pass a merge past the checker, the gap issue 293 closed for the two statuses before it. gitea_commit_status now refuses mesh/check in every state, and the new gitea_check_status writes it alone; the bus grants that tool only to a module whose manifest invokes it, the forge bridge (the lead's choice A).
This commit is contained in:
@@ -91,11 +91,21 @@ const states = new Set(["pending", "success", "error", "failure", "warning"]);
|
||||
/** The merge check's contexts (pulls.ts): a branch's protection may require them. */
|
||||
const mergeCheckContexts = new Set(["mesh/merge-gate", "mesh/repo-check"]);
|
||||
|
||||
/** The one status main requires (novox/hq ADR 0447 §1, issue 1301): the checker's verdict, which the forge bridge
|
||||
* writes through gitea_check_status alone. */
|
||||
export const CHECK_STATUS = "mesh/check";
|
||||
|
||||
/** A status mesh-delivery asks for, checked: one of the forge's states, a context of the mesh's own, a
|
||||
* bounded description. */
|
||||
export function deliveryStatus(context: string, state: string, description: string, target?: string) {
|
||||
if (!/^mesh\/[a-z-]+$/.test(context)) throw new Error(`${context} is not a status of the mesh's own`);
|
||||
if (!states.has(state)) throw new Error(`${state} is not a status the forge keeps`);
|
||||
// mesh/check is the checker's verdict and the one status main requires (ADR 0447 §1): no hand sets it in any
|
||||
// state, as issue 293 closed for the two before it. The forge bridge writes it through gitea_check_status, a tool
|
||||
// only its account may call (issue 1301).
|
||||
if (context === CHECK_STATUS) {
|
||||
throw new Error(`${CHECK_STATUS} is the checker's status: the forge bridge writes it through gitea_check_status, never this tool`);
|
||||
}
|
||||
// The merge check's statuses are its verdict's, set when the controller says it, and a branch may require
|
||||
// them: never set to a verdict by hand, so nothing passes — or fails — a merge past the check (novox/hq
|
||||
// issue 293). Pending is no verdict: it is what this holder says on every new head until the verdict comes,
|
||||
@@ -126,7 +136,7 @@ export interface StatusWriters {
|
||||
export function commitStatusTool(writers: StatusWriters): ToolDefinition {
|
||||
return {
|
||||
name: "gitea_commit_status",
|
||||
description: "Set one of the mesh's statuses on a commit (mesh/delivery, mesh/delivery-group): pending, success, error, failure or warning, a short description, and the page it links to. The merge check's statuses (mesh/merge-gate, mesh/repo-check) take pending only — a recheck putting them back until the fresh verdict sets them — on the head's whole sha, in their turn after the check's earlier writes.",
|
||||
description: "Set one of the mesh's statuses on a commit (mesh/delivery, mesh/delivery-group): pending, success, error, failure or warning, a short description, and the page it links to. mesh/check, the status main requires, is refused: the checker's forge bridge writes it through gitea_check_status. hq's mesh/repo-check (and mesh/merge-gate, kept for a revert) take pending only — a recheck putting them back until the fresh verdict sets them — on the head's whole sha, in their turn after the check's earlier writes.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
@@ -153,3 +163,48 @@ export function commitStatusTool(writers: StatusWriters): ToolDefinition {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** What the check's status tool writes with: the forge. */
|
||||
export interface CheckStatusWriter {
|
||||
setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `gitea_check_status` tool (novox/hq issue 1301, ADR 0447 §1): the one way `mesh/check` is written. Its arguments
|
||||
* are gitea_commit_status's, so the forge bridge sends an owed status's arguments unchanged to it: owner, repo, sha
|
||||
* (the head's whole sha), state, description, target_url, and context, which may be left out and is mesh/check when
|
||||
* given. **Who may write it is the bus's grant**: the runtime tells a tool no caller, so only a module that lists
|
||||
* `gitea.gitea_check_status` in its manifest's `invokes` may call it, and the forge bridge alone does. Served beside
|
||||
* gitea_commit_status, from the watcher's bundle.
|
||||
*/
|
||||
export function checkStatusTool(writer: CheckStatusWriter): ToolDefinition {
|
||||
return {
|
||||
name: "gitea_check_status",
|
||||
description: "Set mesh/check, the checker's verdict and the one status main requires, on a pull request's head: pending, success, error, failure or warning, a short description, and the page it links to. The checker's forge bridge alone may call it (its manifest's invokes); no other status is written here, and no other tool writes this one.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
sha: { type: "string", description: "the head's whole sha" },
|
||||
context: { type: "string", description: "mesh/check, or left out" },
|
||||
state: { type: "string", description: "pending | success | error | failure | warning" },
|
||||
description: { type: "string", description: "one line" },
|
||||
target_url: { type: "string", description: "the page it links to (the check's report)" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const owner = checkName("owner", args.owner), repo = checkName("name", args.repo), sha = String(args.sha ?? "");
|
||||
if (args.context !== undefined && args.context !== null && String(args.context) !== CHECK_STATUS) {
|
||||
throw new Error(`${JSON.stringify(args.context)}: this tool writes ${CHECK_STATUS} alone. Nothing was sent`);
|
||||
}
|
||||
if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha)) {
|
||||
throw new Error(`${JSON.stringify(args.sha)} is not a head's whole sha: ${CHECK_STATUS} names the head whole. Nothing was sent`);
|
||||
}
|
||||
// deliveryStatus checks the state and cuts the description; it refuses mesh/check, so it is asked for a
|
||||
// context of the mesh's own and the status is named mesh/check after.
|
||||
const checked = deliveryStatus("mesh/delivery", String(args.state), String(args.description ?? ""),
|
||||
args.target_url ? String(args.target_url) : undefined);
|
||||
const status = { ...checked, context: CHECK_STATUS };
|
||||
await writer.setCommitStatus(owner, repo, sha, status);
|
||||
return { set: status };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
|
||||
import { emit, on } from "@novox/mesh-sdk/events";
|
||||
import { registerModuleTools } from "@novox/mesh-sdk/tools";
|
||||
import { commitStatusTool } from "./delivery.js";
|
||||
import { checkStatusTool, commitStatusTool } from "./delivery.js";
|
||||
import { announce, keepWatermark, readWatermark } from "./announce.js";
|
||||
import { mergeTool } from "./merge.js";
|
||||
import { GiteaAdmin, GiteaClient, movedSince } from "./client.js";
|
||||
@@ -477,6 +477,8 @@ if (!gitea) pace.unheard("the forge is not watched: no way to a token", false);
|
||||
registerModuleTools("gitea", () => [eventsTool(pace), ...(gitea ? [commitStatusTool({
|
||||
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
|
||||
gates,
|
||||
}), checkStatusTool({
|
||||
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
|
||||
}), mergeTool(gitea, { gates })] : [])]);
|
||||
|
||||
// **A build machine took an ask** (the build seat's `started`, novox/hq ADR 0157): said on the head it checks, when
|
||||
|
||||
@@ -61,3 +61,38 @@ test("one view per pull request, found by its marker; only the mesh's statuses",
|
||||
assert.equal(deliveryStatus("mesh/repo-check", "pending", "checking again: main moved on").context, "mesh/repo-check");
|
||||
assert.equal(deliveryStatus("mesh/delivery", "success", "x", "javascript:alert(1)").target_url, undefined);
|
||||
});
|
||||
|
||||
// Issue 1301-S4 (ADR 0447 §1; the lead's choice A): main requires mesh/check, so no hand may set it. The status tool
|
||||
// every caller may use refuses it in every state; it is written only by gitea_check_status, which the forge bridge
|
||||
// alone lists in its manifest's `invokes`, so only its account may call it.
|
||||
test("mesh/check is refused by the status tool and written by the check's own tool alone", async () => {
|
||||
const { commitStatusTool, checkStatusTool, deliveryStatus, CHECK_STATUS } = await import("../delivery.ts");
|
||||
assert.equal(CHECK_STATUS, "mesh/check");
|
||||
const sha = "0123456789abcdef0123456789abcdef01234567";
|
||||
const sent: string[] = [];
|
||||
const setCommitStatus = async (o: string, r: string, s: string, st: any) =>
|
||||
void sent.push(`${o}/${r}@${s.slice(0, 8)} ${st.context} ${st.state} ${st.description}${st.target_url ? " " + st.target_url : ""}`);
|
||||
const gates = { rechecked: async () => { throw new Error("not the gate writer's"); } };
|
||||
for (const state of ["pending", "success", "failure", "error", "warning"]) {
|
||||
assert.throws(() => deliveryStatus("mesh/check", state, "x"), /mesh\/check is the checker's status/);
|
||||
await assert.rejects(commitStatusTool({ setCommitStatus, gates } as any).run({ owner: "novox", repo: "mesh-sdk", sha,
|
||||
context: "mesh/check", state, description: "by hand" }), /gitea_check_status/);
|
||||
}
|
||||
assert.deepEqual(sent, [], "nothing was sent for a hand-set mesh/check");
|
||||
|
||||
const tool = checkStatusTool({ setCommitStatus });
|
||||
assert.equal(tool.name, "gitea_check_status");
|
||||
for (const k of ["owner", "repo", "sha", "state", "description", "target_url", "context"]) assert.ok(k in tool.input, k);
|
||||
// The bridge's arguments as it sends them for gitea_commit_status: the context may be given, and is mesh/check.
|
||||
await tool.run({ owner: "novox", repo: "mesh-sdk", sha, context: "mesh/check", state: "success", description: "pass",
|
||||
target_url: "https://forge.invalid/novox/mesh-sdk/pulls/1" });
|
||||
await tool.run({ owner: "novox", repo: "mesh-sdk", sha, state: "pending", description: "y".repeat(300) });
|
||||
assert.equal(sent[0], "novox/mesh-sdk@01234567 mesh/check success pass https://forge.invalid/novox/mesh-sdk/pulls/1");
|
||||
assert.ok(sent[1].startsWith("novox/mesh-sdk@01234567 mesh/check pending yyyy"), sent[1]);
|
||||
assert.ok(sent[1].length < 200, "the description is cut as every status's is");
|
||||
await assert.rejects(tool.run({ owner: "novox", repo: "mesh-sdk", sha, context: "mesh/delivery", state: "success" }), /mesh\/check alone/);
|
||||
await assert.rejects(tool.run({ owner: "novox", repo: "mesh-sdk", sha: sha.slice(0, 12), state: "success" }), /whole sha/);
|
||||
await assert.rejects(tool.run({ owner: "novox", repo: "mesh-sdk", sha, state: "green" }), /not a status/);
|
||||
await assert.rejects(tool.run({ owner: "../x", repo: "mesh-sdk", sha, state: "success" }));
|
||||
assert.equal(sent.length, 2, "nothing more was sent");
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ import { GiteaClient } from "../dist/client.js";
|
||||
import { ConfiguredToken } from "../dist/token.js";
|
||||
import { getGiteaTools } from "../dist/tools/index.js";
|
||||
import { mergeTool } from "../dist/merge.js";
|
||||
import { commitStatusTool } from "../dist/delivery.js";
|
||||
import { checkStatusTool, commitStatusTool } from "../dist/delivery.js";
|
||||
import { GateWriter } from "../dist/gate.js";
|
||||
import { judgeApiCall, judgeProtectionChange, ruleCovers, routedPath } from "../dist/guard.js";
|
||||
|
||||
@@ -168,7 +168,7 @@ const gates = { rechecked: (o: string, r: string, sha: string, s: any) => {
|
||||
} };
|
||||
const tools = [...getGiteaTools(client), commitStatusTool({
|
||||
setCommitStatus: (o, r, sha, s) => client.setCommitStatus(o, r, sha, s), gates: writer,
|
||||
}), mergeTool(client, { gates, stateDir, emit: async (type, body) => { state.emitted.push({ type, body }); },
|
||||
}), checkStatusTool({ setCommitStatus: (o, r, sha, s) => client.setCommitStatus(o, r, sha, s) }), mergeTool(client, { gates, stateDir, emit: async (type, body) => { state.emitted.push({ type, body }); },
|
||||
now: () => new Date(2026, 9, 12, 15, 1) })];
|
||||
const tool = (name: string) => tools.find((t) => t.name === name)!;
|
||||
const writes = () => state.sent.map((s) => `${s.method} ${s.path}`);
|
||||
@@ -568,6 +568,10 @@ test("no tool's caller text becomes another path or another user: `..` and `?sud
|
||||
["gitea_get_file", { ...repo, path: "README.md?sudo=jschoubben" }],
|
||||
["gitea_commit_status", { ...repo, sha: "abc1234?sudo=jschoubben", context: "mesh/delivery", state: "success" }],
|
||||
["gitea_commit_status", { ...repo, sha: "../../../admin/users", context: "mesh/delivery", state: "success" }],
|
||||
["gitea_check_status", { ...repo, sha: "abc1234?sudo=jschoubben", state: "success" }],
|
||||
["gitea_check_status", { ...repo, sha: "../../../admin/users", state: "success" }],
|
||||
["gitea_check_status", { owner: "novox", repo: "..", sha: "0123456789abcdef0123456789abcdef01234567", state: "success" }],
|
||||
["gitea_commit_status", { ...repo, sha: "0123456789abcdef0123456789abcdef01234567", context: "mesh/check", state: "success" }],
|
||||
["gitea_commit_statuses", { ...repo, sha: ".." }],
|
||||
["gitea_commit_statuses", { ...repo, sha: "main?sudo=jschoubben" }],
|
||||
["gitea_branch_protection_set", { ...repo, branch: "..", status_checks: "mesh/merge-gate" }],
|
||||
|
||||
Reference in New Issue
Block a user