Branch protection requires mesh/merge-gate (and mesh/repo-check on the core repositories) with no admin override, and the forge combines warning as a failure. A note is now a success that says it; a repository without a merge-check.sh is a success where repo-check is not required and a failure for a person where it is; the status tool refuses the merge check's contexts.
204 lines
10 KiB
TypeScript
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 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);
|
|
}
|
|
|
|
// **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;
|
|
}
|