Once main requires mesh/check, nothing writes mesh/merge-gate, and the merge tool, which judged on it, would refuse every merge. It now judges on the status the base requires: mesh/check's line names the trunk it judged, an out-of-date one is refused and nothing is asked (the checker re-checks on a trunk move, ADR 0447 §4); hq's mesh/repo-check is judged as before. The announcer, the verdict, a trunk's move and a build's start write the controller's two statuses only where a base still requires them, so hq keeps mesh/repo-check and no stale mesh/merge-gate appears anywhere else.
211 lines
13 KiB
TypeScript
211 lines
13 KiB
TypeScript
// What the forge's holder does for a delivery (novox/hq ADR 0239): the commit's note under
|
|
// refs/notes/mesh-plan, the delivery's view on its pull request, and its statuses — asked by mesh-delivery,
|
|
// the delivery's owner, through this module's tools. The forge is this module's; mesh-delivery never writes
|
|
// to it itself.
|
|
//
|
|
// **The note is written in the forge's own repository, as the forge's own user.** The forge's API reads a
|
|
// note and writes none, and a push needs a clone and a credential nobody else should hold; the forge's
|
|
// container holds the bare repository and git. So the line is appended there, by `git notes append`, as the
|
|
// account the forge runs as — the mesh's own forge, never a person's or an agent's hand. Appending is
|
|
// idempotent here: a line the note already has is not added again, so asking twice adds nothing.
|
|
//
|
|
// Pure functions and an injectable runner, so this is tested without a forge.
|
|
|
|
import { execFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import type { ToolDefinition } from "@novox/mesh-sdk/tools";
|
|
import type { CommitStatus } from "./client.js";
|
|
import { checkName } from "./guard.js";
|
|
import { CHECK_STATUS } from "./pulls.js";
|
|
|
|
export { CHECK_STATUS };
|
|
|
|
/** How a command is run: docker on the forge's machine, or a test's. */
|
|
export type Runner = (file: string, args: string[]) => Promise<{ stdout: string; code: number }>;
|
|
|
|
const execFileP = promisify(execFile);
|
|
|
|
export const run: Runner = async (file, args) => {
|
|
try {
|
|
const { stdout } = await execFileP(file, args, { maxBuffer: 4 << 20 });
|
|
return { stdout, code: 0 };
|
|
} catch (err) {
|
|
const e = err as { code?: number; stdout?: string };
|
|
return { stdout: e.stdout ?? "", code: typeof e.code === "number" ? e.code : 1 };
|
|
}
|
|
};
|
|
|
|
const name = /^[A-Za-z0-9_.-]+$/;
|
|
const sha = /^[0-9a-fA-F]{7,64}$/;
|
|
const ref = /^[a-z0-9][a-z0-9-]*$/;
|
|
|
|
/** A note line as it may be written: one line, bounded, nothing that is not text. */
|
|
export function noteLine(line: string): string {
|
|
const one = String(line ?? "").replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim();
|
|
if (!one) throw new Error("an empty line is no note");
|
|
return one.length > 2000 ? one.slice(0, 1999) + "…" : one;
|
|
}
|
|
|
|
/** Where the forge keeps a repository, inside its container. */
|
|
export function gitDir(owner: string, repo: string): string {
|
|
if (!name.test(owner) || !name.test(repo)) throw new Error(`${owner}/${repo} is not a repository's name`);
|
|
return `/data/git/repositories/${owner.toLowerCase()}/${repo.toLowerCase()}.git`;
|
|
}
|
|
|
|
/** The commands the note is read and appended with, in the forge's container, as its user. */
|
|
export function noteArgs(container: string, owner: string, repo: string, commit: string, notesRef: string, line?: string): string[] {
|
|
if (!name.test(container)) throw new Error(`${container} is not a container's name`);
|
|
if (!sha.test(commit)) throw new Error(`${commit} is not a commit`);
|
|
if (!ref.test(notesRef)) throw new Error(`${notesRef} is not a notes ref`);
|
|
const base = ["exec", "-u", "git", container, "git", "--git-dir", gitDir(owner, repo),
|
|
"-c", "user.name=mesh", "-c", "user.email=mesh@mesh.invalid", "notes", `--ref=${notesRef}`];
|
|
return line === undefined ? [...base, "show", commit] : [...base, "append", "-m", noteLine(line), commit];
|
|
}
|
|
|
|
/** Append a line to a commit's note, unless the note already holds it. Answers whether it was added. */
|
|
export async function appendNote(runner: Runner, container: string, owner: string, repo: string, commit: string,
|
|
notesRef: string, line: string): Promise<{ added: boolean; lines: number }> {
|
|
const wanted = noteLine(line);
|
|
const shown = await runner("docker", noteArgs(container, owner, repo, commit, notesRef));
|
|
// No note yet is git's exit 1 with nothing on stdout; anything else unreadable is said.
|
|
const lines = shown.code === 0 ? shown.stdout.split("\n").filter((l) => l.trim() !== "") : [];
|
|
if (lines.includes(wanted)) return { added: false, lines: lines.length };
|
|
const appended = await runner("docker", noteArgs(container, owner, repo, commit, notesRef, wanted));
|
|
if (appended.code !== 0) throw new Error(`the note on ${commit.slice(0, 8)} could not be appended (git exited ${appended.code})`);
|
|
return { added: true, lines: lines.length + 1 };
|
|
}
|
|
|
|
/** The marker that makes one comment of a pull request the delivery's view. */
|
|
export const VIEW_MARKER = "<!-- mesh-delivery:view -->";
|
|
|
|
/** The view's body, marked: mesh-delivery writes it, this keeps exactly one of them per pull request. */
|
|
export function viewBody(body: string): string {
|
|
const b = String(body ?? "");
|
|
return b.includes(VIEW_MARKER) ? b : `${VIEW_MARKER}\n${b}`;
|
|
}
|
|
|
|
/** Which comment is the view: the first that carries the marker, or none yet. */
|
|
export function viewComment<T extends { id: number; body: string }>(comments: T[]): T | undefined {
|
|
return comments.find((c) => c.body.includes(VIEW_MARKER));
|
|
}
|
|
|
|
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"]);
|
|
|
|
|
|
/** 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,
|
|
// and what a recheck of the same head says, so the old verdict does not let it merge while the fresh check
|
|
// runs.
|
|
if (mergeCheckContexts.has(context) && state !== "pending") {
|
|
throw new Error(`${context} is the merge check's status, set by its verdict only (pending, for a check asked again, is the one state it takes by hand)`);
|
|
}
|
|
let d = String(description ?? "").replace(/\s+/g, " ").trim();
|
|
if (d.length > 140) d = d.slice(0, 139) + "…";
|
|
return { state: state as "pending" | "success" | "error" | "failure" | "warning", context, description: d,
|
|
...(target && /^https?:\/\//.test(target) ? { target_url: target } : {}) };
|
|
}
|
|
|
|
/** What the status tool writes with: the forge, and the gate writer the merge check's statuses go through. */
|
|
export interface StatusWriters {
|
|
setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void>;
|
|
gates: { rechecked(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void> };
|
|
}
|
|
|
|
/**
|
|
* The `gitea_commit_status` tool. **The merge check's pending goes through the module's one writer** (novox/hq issue
|
|
* 456): a recheck's pending on mesh/merge-gate or mesh/repo-check takes its turn on the head's chain (gate.ts), so an
|
|
* old run's verdict in flight lands before it and a redelivered one is dropped. Served from the watcher's bundle
|
|
* (index.ts), the process that holds that writer: each bundle the runtime serves is its own process. The mesh's other
|
|
* statuses (mesh/delivery, mesh/delivery-group) are written as they were.
|
|
*/
|
|
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. 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" },
|
|
sha: { type: "string", description: "the commit" },
|
|
context: { type: "string", description: "the status's name, mesh/…" },
|
|
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 pull request)" },
|
|
},
|
|
run: async (args) => {
|
|
const owner = checkName("owner", args.owner), repo = checkName("name", args.repo), sha = String(args.sha ?? "");
|
|
const status = deliveryStatus(String(args.context), String(args.state), String(args.description ?? ""),
|
|
args.target_url ? String(args.target_url) : undefined);
|
|
if (mergeCheckContexts.has(status.context)) {
|
|
// The writer keeps a head by its whole sha, as the forge announced it: a short one would be another head.
|
|
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: the merge check's status names the head whole. Nothing was sent`);
|
|
}
|
|
await writers.gates.rechecked(owner, repo, sha, status);
|
|
} else {
|
|
await writers.setCommitStatus(owner, repo, sha, status);
|
|
}
|
|
return { set: status };
|
|
},
|
|
};
|
|
}
|
|
|
|
/** 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 };
|
|
},
|
|
};
|
|
}
|