Files
mesh-catalog/modules/gitea/pulls.ts
T
jochen b6f0bc309b
mesh/merge-gate fail: a manifest the change touches fails the module check: modules/mesh-delivery/module.json: this manifest cannot be used:
mesh/delivery delivered
mesh/delivery-group group feat/mesh-delivery delivered: every member is delivered
Add mesh-delivery, the owner of deliveries and delivery groups (hq ADR 0239)
One module answers 'did my change go out' for a commit and orders a
cross-repository change: one compiled state table, its state on the bus,
every transition said, noted on the commit and shown on the pull request.
The forge's holder gains the note, view and status tools it asks with, and
says closed pull requests and a merge's head and statuses.
2026-10-06 23:59:54 +02:00

146 lines
7.1 KiB
TypeScript

// A pull request's merge check (novox/hq to-be 45 §9): what the forge's announcer says when a pull
// request's head moves, and what it sets as the pull request's status when the mesh says the verdict.
//
// **Before merge, never after.** Every check the mesh had ran after a merge, on a machine: a manifest the
// node-engine refuses (issue 236), an identity a real machine's name made too long (263). So each new head
// of an open pull request is announced as `pull.updated`; the controller asks the build seat to check it
// against every machine of the mesh's facts; and the verdict comes back as the controller's `checked`,
// which this sets as the head commit's status — and, when it is not a pass, as a comment saying why.
//
// Pure functions here, so they are tested without a forge; index.ts does the asking and the setting.
import type { CommitStatus, GiteaPull } from "./client.js";
/** The status context a merge check's gate is kept under: one per commit, the newest replacing the last. */
export const CHECK_CONTEXT = "mesh/merge-gate";
/** The status context of the repository's own merge-check.sh, the check's second layer (novox/hq ADR 0237). */
export const REPO_CHECK_CONTEXT = "mesh/repo-check";
/** One layer of a check, judged. */
export interface Layer {
verdict: string;
summary: string;
/** The modules a merge of the change would move, as the controller's planner reckons it… */
modules?: string[];
/** …and those it would build after them because they stand on them. */
dependents?: string[];
}
/** What the controller says as `checked` (mesh-controller internal/link, Checked). */
export interface Checked {
owner: string;
repo: string;
number?: number;
commit: string;
verdict: string;
summary: string;
report?: string;
id: string;
on?: string;
/** The gate — the modules of the mesh's graph the change touches — and the repository's own check. A
* controller from before the layers says neither, and its verdict is the gate's. */
gate?: Layer;
"repo-check"?: Layer;
/** The change plan of the commit checked (novox/hq ADR 0238): what a merge of it would build and send. */
plan?: ChangePlan;
/** Set on a delivery group's composed check (novox/hq ADR 0239): not this head's merge gate. */
group?: string;
}
/** A change plan, as the controller says it (mesh-controller internal/link, ChangePlan). */
export interface ChangePlan {
repository: string;
base: string;
head: string;
moved?: string[];
dependents?: string[];
new?: string[];
unread?: string[];
tiers?: string[][];
machines?: { machine: string; receives?: string[]; waits?: string[] }[];
steps?: string[];
summary: string;
}
/** Whether a plan builds anything: a change that touches the mesh's graph. */
function builds(plan?: ChangePlan): boolean {
return !!plan && ((plan.moved?.length ?? 0) > 0 || (plan.new?.length ?? 0) > 0);
}
/** A change plan as a person reads it on the pull request. */
export function planText(plan: ChangePlan): string {
const lines = [`**Change plan** — ${plan.summary}`];
(plan.tiers ?? []).forEach((tier, i) => lines.push(`- tier ${i}: ${tier.join(", ")}`));
for (const m of plan.machines ?? []) {
const parts: string[] = [];
if (m.receives?.length) parts.push(`receives ${m.receives.join(", ")}`);
if (m.waits?.length) parts.push(`waits for a person: ${m.waits.join(", ")}`);
lines.push(`- ${m.machine}: ${parts.join("; ")}`);
}
for (const step of plan.steps ?? []) lines.push(`- ${step}`);
if (plan.unread?.length) lines.push(`- read by no module's build: ${plan.unread.join(", ")}`);
return lines.join("\n");
}
/** The heads already announced, keyed `owner/repo#number`, so a restart announces nothing twice. */
export type Announced = Record<string, string>;
/** Which open pull requests have a head not yet announced. A pull request merged or closed is not
* open and is never asked about. */
export function headsToAnnounce(full: string, pulls: GiteaPull[], announced: Announced): GiteaPull[] {
return pulls.filter((p) => p.state === "open" && !!p.head_sha && announced[`${full}#${p.number}`] !== p.head_sha);
}
/** The forge's state for a verdict: an error is the forge's `error`, never a success. */
function stateOf(verdict: string): CommitStatus["state"] {
return verdict === "pass" ? "success" : verdict === "warning" ? "warning" : verdict === "fail" ? "failure" : "error";
}
function described(verdict: string, summary: string, modules?: string[], dependents?: string[]): string {
// The forge keeps a short description; the rest is the comment's.
let d = `${verdict || "error"}: ${summary}`;
if (modules?.length) d += ` [${modules.join(", ")}${dependents?.length ? ` +${dependents.length} dependent(s)` : ""}]`;
d = d.replace(/\s+/g, " ").trim();
return d.length > 140 ? d.slice(0, 139) + "…" : d;
}
/** The forge's status for the gate. */
export function statusFor(c: Checked): CommitStatus {
const gate = c.gate ?? { verdict: c.verdict, summary: c.summary };
// With a plan, the status says what the change does and how it was judged: "pass: builds gitea → anchor;
// no bus step; every machine composes…".
const description = builds(c.plan)
? described(gate.verdict, `${c.plan!.summary}; ${gate.summary}`)
: described(gate.verdict, gate.summary, gate.modules, gate.dependents);
return { state: stateOf(gate.verdict), context: CHECK_CONTEXT, description };
}
/** Every status a verdict sets: the gate's, and the repository's own check's when it was said. */
export function statusesFor(c: Checked): CommitStatus[] {
const out = [statusFor(c)];
const repo = c["repo-check"];
if (repo) out.push({ state: stateOf(repo.verdict), context: REPO_CHECK_CONTEXT, description: described(repo.verdict, repo.summary) });
return out;
}
/** The comment a verdict leaves on its pull request, with the check's own account: the change plan of a
* change that builds something (novox/hq ADR 0238), and why, when the gate is not a pass or the
* repository's own check failed or could not run. A repository with no merge-check.sh, touching nothing,
* is said by its statuses alone, not by a comment on every push. */
export function commentFor(c: Checked): string | null {
const gate = c.gate ?? { verdict: c.verdict, summary: c.summary };
const repo = c["repo-check"];
const repoWrong = !!repo && repo.verdict !== "pass" && repo.verdict !== "warning";
if (gate.verdict === "pass" && !repoWrong && !builds(c.plan)) return null;
const lines = [`**Merge check** at \`${c.commit.slice(0, 8)}\``, ""];
lines.push(`- \`${CHECK_CONTEXT}\`: **${(gate.verdict || "error").toUpperCase()}** — ${gate.summary}` +
(gate.modules?.length ? ` (modules: ${gate.modules.join(", ")}` +
(gate.dependents?.length ? `; built after them: ${gate.dependents.join(", ")}` : "") + ")" : ""));
if (repo) lines.push(`- \`${REPO_CHECK_CONTEXT}\`: **${(repo.verdict || "error").toUpperCase()}** — ${repo.summary}`);
if (builds(c.plan)) lines.push("", planText(c.plan!));
const ran = c.on ? `\n\nRun by the build seat on ${c.on} as \`${c.id}\` (\`builds --log ${c.id}\`).` : "";
const report = c.report ? `\n\n\`\`\`\n${c.report.replace(/```/g, "'''")}\n\`\`\`` : "";
return lines.join("\n") + ran + report;
}