Files
mesh-catalog/modules/gitea/delivery.ts
T
jschoubben 5053e96d4e
mesh/merge-gate pass: builds gitea → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
Route a recheck's pending through the gitea module's one writer of mesh/merge-gate
The recheck's pending was written from the tools' bundle, its own process, past the
writer's per-head chain: the first run's verdict mark stayed set, so a rechecked head
never said where its new run builds, and an old run's verdict in flight or redelivered
could land over the pending. The status tool is now served from the watcher's bundle
and writes the merge check's pending through the writer, and a verdict whose ask
(checked.id) predates the check now pending is dropped, weighed after a restart against
the forge's own history of the gate (novox/hq issue 456).
2026-10-12 12:20:23 +02:00

156 lines
9.2 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";
/** 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`);
// 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. 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.",
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 };
},
};
}