Files
mesh-catalog/modules/gitea/pulls.ts
T
jochen 73bb597c16
mesh/merge-gate pass: builds docker, gitea, lab, nftables, slack, systemd → ace, g14, novox, shanks; no bus step; 4 wait(s) for a person; every machine com…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Hold what the tools say to the glossary's retired words (hq ADR 0244)
An agent meets the mesh's words most often in tool descriptions, and
nothing compared them with the glossary: several still said "the host"
for the node-engine and the forge's pull request comment was headed
"Change plan", a word retired twice over. retired-words is the copy of
the words the glossary retires for the tools, and checks/words fails the
repository check when any string a module's code can show, or any
manifest description, uses one. Those found are reworded here.
2026-10-07 20:02:09 +02:00

204 lines
10 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 delivery plan (the controller's ChangePlan) as a person reads it on the pull request. */
export function planText(plan: ChangePlan): string {
const lines = [`**Delivery 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);
}
// **A required status is success or it blocks** (novox/hq issue 293). The forge combines `warning` as a
// failure, and a branch whose protection requires mesh/merge-gate or mesh/repo-check — with no
// administrator override — will not merge past one. So the controller's `warning`, a note and never a
// question, is set as `success` with the note in its description; only what a person must decide is a
// `failure`, with why. The mapping, verdict by verdict:
//
// pass → success
// warning (a note: rebuild width, a problem
// already so on the base, a script's own) → success, "pass, with a note: …"
// repo-check, no merge-check.sh, not required → success, "no repository check defined"
// repo-check, no merge-check.sh, required (or
// the protection unreadable) → failure: a person adds one, or lifts the requirement
// fail → failure
// error, or no verdict → error — never a success
/** The forge's state for a verdict: an error is the forge's `error`, never a success; a warning is a note. */
function stateOf(verdict: string): CommitStatus["state"] {
return verdict === "pass" || verdict === "warning" ? "success" : verdict === "fail" ? "failure" : "error";
}
/** How a verdict is said in a status's description: a warning as the pass with a note it is. */
function saidAs(verdict: string): string {
return verdict === "warning" ? "pass, with a note" : verdict || "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 = `${saidAs(verdict)}: ${summary}`;
if (modules?.length) d += ` [${modules.join(", ")}${dependents?.length ? ` +${dependents.length} dependent(s)` : ""}]`;
return clipped(d);
}
function clipped(d: string): string {
d = d.replace(/\s+/g, " ").trim();
return d.length > 140 ? d.slice(0, 139) + "…" : d;
}
/** What the forge says of a repository's own check beyond the controller's verdict, asked by index.ts only
* when the verdict is a warning: whether the head holds a merge-check.sh, and whether the base branch's
* protection requires mesh/repo-check. Unknown is undefined. */
export interface RepoCheckFacts {
defined?: boolean;
required?: boolean;
}
/** The description of a required repository check that is not defined. */
export const UNDEFINED_REQUIRED = "fail: mesh/repo-check is required here and this head defines no merge-check.sh — " +
"a person adds one, or lifts the requirement from the branch's protection";
/** The repository check's status: its verdict, unless the repository defines no check at all — then
* success where the check is not required, and a failure for a person where it is (or may be). */
function repoStatus(repo: Layer, facts: RepoCheckFacts): CommitStatus {
if (repo.verdict === "warning" && facts.defined === false) {
return facts.required === false
? { state: "success", context: REPO_CHECK_CONTEXT, description: "no repository check defined" }
: { state: "failure", context: REPO_CHECK_CONTEXT, description: clipped(UNDEFINED_REQUIRED) };
}
return { state: stateOf(repo.verdict), context: REPO_CHECK_CONTEXT, description: described(repo.verdict, repo.summary) };
}
/** Whether the repository check is a failure a person must read: failed, could not run, or required and
* not defined. */
function repoWrong(repo: Layer | undefined, facts: RepoCheckFacts): boolean {
return !!repo && repoStatus(repo, facts).state !== "success";
}
/** 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, facts: RepoCheckFacts = {}): CommitStatus[] {
const out = [statusFor(c)];
const repo = c["repo-check"];
if (repo) out.push(repoStatus(repo, facts));
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, facts: RepoCheckFacts = {}): string | null {
const gate = c.gate ?? { verdict: c.verdict, summary: c.summary };
const repo = c["repo-check"];
const wrong = repoWrong(repo, facts);
if (gate.verdict === "pass" && !wrong && !builds(c.plan)) return null;
const lines = [`**Merge check** at \`${c.commit.slice(0, 8)}\``, ""];
lines.push(`- \`${CHECK_CONTEXT}\`: **${saidAs(gate.verdict).toUpperCase()}** — ${gate.summary}` +
(gate.modules?.length ? ` (modules: ${gate.modules.join(", ")}` +
(gate.dependents?.length ? `; built after them: ${gate.dependents.join(", ")}` : "") + ")" : ""));
if (repo) {
const st = repoStatus(repo, facts);
lines.push(`- \`${REPO_CHECK_CONTEXT}\`: ` + (repo.verdict === "warning" && facts.defined === false
? `**${st.state === "success" ? "PASS" : "FAIL"}** — ${st.description.replace(/^fail: /, "")}`
: `**${saidAs(repo.verdict).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;
}