// 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; /** 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; }