Files
mesh-catalog/modules/gitea/pulls.ts
T
mesh-toolsandClaude de9d29dc65
mesh/merge-gate head de9d29dc657d merged into trunk 62ec68355cd1 — pass: builds gitea, mesh-delivery → novox; no bus step; every machine composes with the …
mesh/repo-check head de9d29dc657d merged into trunk 62ec68355cd1 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh/delivery-group group fix/867-group-siblings delivered: every member is delivered
Keep a group member's siblings and refuse its merge until the group passed and every sibling is reviewed (issue 867, ADR 0382)
mesh-delivery's groups names each member's pull request, head, base and whether it is open (member_details), so the controller can read a member's siblings beside its check. The forge's holder keeps the siblings each verdict read, puts that verdict out of date when a sibling gets a new head, closes, or its base moves by anything but its own merge, and the merge tool merges such a member only when mesh/delivery-group passed at the heads read and each sibling head is approved. The statuses name the siblings, or say they could not be learned.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-15 23:27:13 +00:00

295 lines
16 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, GiteaClient, GiteaPull, GiteaRepo } from "./client.js";
import { movedSince } from "./client.js";
import { UNKNOWN_MARK, besideWords, siblingsOf } from "./siblings.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";
/**
* The pending words a head's merge check is announced with (novox/hq issue 446): where the check is, and since when,
* in the time the operator reads. They stood as "the mesh is mapping this head onto its module graph" for the whole
* check — the few seconds the controller maps the head and then the queue wait and the build — and readers took the
* forge for lagging (issue 423). The controller maps and queues the check in seconds, so from the announcement it waits
* in the build queue; mesh-delivery says "building on <machine> since …" when a build machine takes it.
*/
export function pendingWords(announced: Date): string {
const two = (n: number) => String(n).padStart(2, "0");
return `waiting in the build queue since ${two(announced.getHours())}:${two(announced.getMinutes())}`;
}
/** 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;
/** The trunk commit the head was merged into and judged as, twelve characters (novox/hq ADR 0323 §3); absent from a
* controller whose build seat judged the head alone. Beside are the other repositories' commits the check read,
* by directory; took how long it ran. */
trunk?: string;
beside?: Record<string, string>;
took?: string;
/** The other members of its delivery group the check read beside the head, each at its head merged onto its base
* commit (novox/hq ADR 0382 §4); absent when it read none, and from a controller from before the rule. */
siblings?: { repository: string; number: number; head: string; base: string; base_commit: string }[];
/** Why the siblings could not be learned (mesh-delivery did not answer), when that is so. */
siblings_unknown?: string;
/** Where the judge came from, when it came from the group (ADR 0382 §3): said in the account. */
judge_from?: 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 pull request the forge found updated, by its repository: what `searchOpenPulls` answers. */
export interface PullSeen {
owner: string;
name: string;
updated_at?: string;
}
/** **The repositories a pass looks at** (novox/hq issue 347): every one that moved since the floor, every one
* holding a pull request updated since the floor — opening, reopening or retargeting a pull request moves its own
* update time and not its repository's — and every one on a sweep or the first pass. */
export function reposToLook(repos: GiteaRepo[], floor: string, pulls: PullSeen[], sweep: boolean): GiteaRepo[] {
if (sweep || !floor) return repos;
const moved = new Set(movedSince(repos, floor).map((r) => r.full_name));
const at = Date.parse(floor);
for (const p of pulls) {
if (!p.updated_at || !(Date.parse(p.updated_at) < at)) moved.add(`${p.owner}/${p.name}`);
}
return repos.filter((r) => moved.has(r.full_name));
}
// **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);
}
export 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;
}
// **What only the forge knows of a repository check said as a warning** (novox/hq issue 293): whether the
// head holds a merge-check.sh at all, and — when it does not — whether the base branch's protection
// requires mesh/repo-check. Asked only for a warning; unknown is left undefined, which statusesFor reads
// as possibly required: a failure on a status nothing requires blocks nothing, a success on one that is
// required would let an untested repository merge. The base's protection is the one the forge applies, read
// from the branch: the rules' own list is a repository administrator's, which the tools' account is not
// (novox/hq ADR 0365).
export async function repoCheckFacts(
forge: Pick<GiteaClient, "holdsFile" | "appliedProtection">, c: Checked, base: string | undefined): Promise<RepoCheckFacts> {
if (c["repo-check"]?.verdict !== "warning") return {};
const defined = await forge.holdsFile(c.owner, c.repo, c.commit, "merge-check.sh").catch(() => undefined);
if (defined !== false) return { defined };
const rule = await forge.appliedProtection(c.owner, c.repo, base || "main").catch(() => undefined);
if (rule === undefined) return { defined };
const required = !!rule?.enable_status_check && (rule.status_check_contexts ?? []).includes(REPO_CHECK_CONTEXT);
return { defined, required };
}
/** 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 judgedFirst(c, { 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(judgedFirst(c, repoStatus(repo, facts)));
return out;
}
/** **A status names what its verdict judged, first** (novox/hq ADR 0323 §3): "head 1a2b3c4d5e6f merged into trunk
* 7a8b9c0d1e2f — pass: …", so the forge's 140 characters never clip it away; the trunk's move is weighed by it. A
* verdict that names no trunk commit is said as before. */
function judgedFirst(c: Checked, status: CommitStatus): CommitStatus {
// The siblings it read, named after the commits (ADR 0382 §4), so the clip never takes them either.
const siblings = siblingsOf(c.siblings);
// A verdict that could not learn them says so on the status too (clause 1: it is still checked, and merges).
const mark = siblings.length ? besideWords(siblings) : c.siblings_unknown ? UNKNOWN_MARK : "";
if (!c.trunk) return mark ? { ...status, description: clipped(`${mark} — ${status.description}`) } : status;
return { ...status, description: clipped(`head ${c.commit.slice(0, 12)} merged into trunk ${c.trunk.slice(0, 12)}${mark ? " " + mark : ""} — ${status.description}`) };
}
/** 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)}\``, ""];
if (c.trunk) {
// What was judged (novox/hq ADR 0323 §3): the head merged into the trunk commit, and what was read beside it.
const beside = Object.entries(c.beside ?? {}).map(([d, sha]) => `${d} \`${sha}\``).join(", ");
lines.splice(1, 0, `Judged: head \`${c.commit.slice(0, 12)}\` merged into trunk \`${c.trunk.slice(0, 12)}\`` +
(beside ? `; read beside it: ${beside}` : ""));
}
const siblings = siblingsOf(c.siblings);
if (siblings.length) {
lines.splice(c.trunk ? 2 : 1, 0, `Siblings read beside it: ` + siblings.map((s) =>
`${s.repository}#${s.number} \`${s.head.slice(0, 12)}\` merged onto \`${s.base_commit.slice(0, 12)}\` (${s.base})`).join(", ") +
(c.judge_from ? `; judged by ${c.judge_from}` : ""));
} else if (c.siblings_unknown) {
lines.splice(c.trunk ? 2 : 1, 0, `Siblings could not be learned: ${c.siblings_unknown}`);
}
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;
}