Merge pull request 'Answer what the mesh's checks said of a pull request: mesh-delivery's checks verb (hq ADR 0239)' (#109) from feat/delivery-checks-verb into main

This commit was merged in pull request #109.
This commit is contained in:
2026-10-07 19:04:10 +00:00
14 changed files with 919 additions and 15 deletions
+83
View File
@@ -0,0 +1,83 @@
// What the forge says of a commit's checks (novox/hq ADR 0239): every status it keeps for the commit, the
// newest of each context, and whether the branch it merges into would let it merge — every status that
// branch's protection requires being a success. Read by mesh-delivery's `checks` verb, which joins it to the
// verdict it keeps; the forge's statuses are what the merge button reads, so they are asked of the forge.
//
// Pure functions, so they are tested without a forge; the tool does the asking.
import type { BranchProtection } from "./protection.js";
/** One status of a commit as the forge keeps it. */
export interface StatusRead {
context: string;
state: string;
description: string;
target_url?: string;
created_at?: string;
updated_at?: string;
creator?: string;
}
/** The forge's statuses of a commit, the newest of each context, in context order. The forge answers the
* newest first; a status it says without a context is not one anything can require, and is left out. */
export function newestByContext(raw: unknown[]): StatusRead[] {
const seen = new Map<string, StatusRead>();
const stamp = (s: StatusRead) => s.updated_at || s.created_at || "";
for (const r of raw ?? []) {
const s = r as Record<string, any>;
const context = String(s?.context ?? "").trim();
if (!context) continue;
const read: StatusRead = {
context,
state: String(s.status ?? s.state ?? ""),
description: String(s.description ?? ""),
};
if (s.target_url) read.target_url = String(s.target_url);
if (s.created_at) read.created_at = String(s.created_at);
if (s.updated_at) read.updated_at = String(s.updated_at);
const creator = s.creator?.login ?? s.creator?.username;
if (creator) read.creator = String(creator);
const before = seen.get(context);
if (!before || stamp(read) > stamp(before)) seen.set(context, read);
}
return [...seen.values()].sort((a, b) => a.context.localeCompare(b.context));
}
/** Whether a commit may merge into a branch, as its protection reads the statuses. */
export interface MergeableRead {
/** The branch the protection is read for. */
branch: string;
/** Whether the branch has a rule at all, and whether it requires statuses. */
protected: boolean;
required: string[];
/** Every required status that is not a success, with its state ("missing" when the commit has none). */
blocking: { context: string; state: string }[];
/** True when every required status is a success; null when the protection could not be read. */
mergeable: boolean | null;
/** In words. */
says: string;
}
/** Whether the statuses let the commit merge under the branch's rule. A rule that cannot be read says
* nothing, never yes. A warning blocks: the forge combines it as a failure (novox/hq issue 293). */
export function mergeableUnder(branch: string, rule: BranchProtection | null | undefined, statuses: StatusRead[],
unreadable?: string): MergeableRead {
if (unreadable !== undefined) {
return { branch, protected: false, required: [], blocking: [], mergeable: null,
says: `the protection of ${branch} could not be read: ${unreadable}` };
}
const required = rule && rule.enable_status_check ? [...new Set((rule.status_check_contexts ?? []).filter(Boolean))] : [];
const by = new Map(statuses.map((s) => [s.context, s.state]));
const blocking = required
.filter((c) => by.get(c) !== "success")
.map((c) => ({ context: c, state: by.get(c) ?? "missing" }));
const says = !rule
? `${branch} has no protection: nothing is required`
: required.length === 0
? `${branch} requires no status`
: blocking.length === 0
? `every status ${branch} requires is a success: ${required.join(", ")}`
: `${branch} requires ${required.join(", ")}; not a success: ` +
blocking.map((b) => `${b.context} (${b.state})`).join(", ");
return { branch, protected: !!rule, required, blocking, mergeable: blocking.length === 0, says };
}
+7
View File
@@ -348,6 +348,13 @@ export class GiteaClient {
return out;
}
/** A commit's statuses whole, as the forge combines them for a ref (a sha, or the start of one): the commit
* it resolved to and every status with its description, link, times and setter (novox/hq ADR 0239). */
async combinedStatus(owner: string, repo: string, ref: string): Promise<{ sha: string; state: string; statuses: unknown[] }> {
const raw = await this.request<any>(`/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}/status?limit=50`);
return { sha: String(raw?.sha ?? ""), state: String(raw?.state ?? ""), statuses: Array.isArray(raw?.statuses) ? raw.statuses : [] };
}
/** Replace a comment's body: the delivery's view, kept current in place (novox/hq ADR 0239). */
async editComment(owner: string, repo: string, id: number, body: string): Promise<{ id: number; html_url: string }> {
const c = await this.request<any>(`/repos/${owner}/${repo}/issues/comments/${id}`, { method: "PATCH", body: JSON.stringify({ body }) });
+48
View File
@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import { test } from "node:test";
// What the forge says of a commit's checks (novox/hq ADR 0239): read by mesh-delivery's `checks` verb.
test("the newest status of each context is kept, with what it said, when and by whom", async () => {
const { newestByContext } = await import("../checks.ts");
const read = newestByContext([
{ context: "mesh/merge-gate", status: "success", description: "pass: every machine composes", updated_at: "2026-10-07T10:00:00Z",
creator: { login: "mesh-admin" }, target_url: "https://forge.example/o/r/pulls/1" },
{ context: "mesh/merge-gate", status: "failure", description: "fail: 0 of 4 compose", updated_at: "2026-10-07T09:00:00Z" },
{ context: "mesh/repo-check", state: "error", description: "error: the toolchain was not there", created_at: "2026-10-07T10:01:00Z" },
{ status: "success", description: "a status with no context" },
]);
assert.deepEqual(read.map((s) => [s.context, s.state]), [["mesh/merge-gate", "success"], ["mesh/repo-check", "error"]]);
assert.equal(read[0].creator, "mesh-admin");
assert.equal(read[0].description, "pass: every machine composes");
assert.equal(read[1].created_at, "2026-10-07T10:01:00Z");
});
test("a commit merges only when every required status is a success; a warning blocks", async () => {
const { mergeableUnder } = await import("../checks.ts");
const rule = { rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"] };
const ok = mergeableUnder("main", rule, [
{ context: "mesh/merge-gate", state: "success", description: "" },
{ context: "mesh/repo-check", state: "success", description: "" },
]);
assert.equal(ok.mergeable, true);
assert.deepEqual(ok.blocking, []);
const not = mergeableUnder("main", rule, [{ context: "mesh/merge-gate", state: "warning", description: "" }]);
assert.equal(not.mergeable, false);
assert.deepEqual(not.blocking, [{ context: "mesh/merge-gate", state: "warning" }, { context: "mesh/repo-check", state: "missing" }]);
assert.match(not.says, /mesh\/repo-check \(missing\)/);
});
test("no rule requires nothing; a rule that cannot be read says nothing, never yes", async () => {
const { mergeableUnder } = await import("../checks.ts");
const none = mergeableUnder("main", null, []);
assert.equal(none.mergeable, true);
assert.equal(none.protected, false);
const off = mergeableUnder("main", { rule_name: "main", enable_status_check: false, status_check_contexts: ["mesh/merge-gate"] }, []);
assert.equal(off.mergeable, true);
assert.deepEqual(off.required, []);
const unread = mergeableUnder("main", undefined, [], "Gitea API: 500");
assert.equal(unread.mergeable, null);
assert.match(unread.says, /could not be read/);
});
+42
View File
@@ -12,6 +12,7 @@ import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"
import { emit } from "@novox/mesh-sdk/events";
import { GiteaClient, type BranchProtection } from "../client.js";
import { appendNote, deliveryStatus, run, viewBody, viewComment } from "../delivery.js";
import { mergeableUnder, newestByContext } from "../checks.js";
/** The forge's container, where its repositories and git are: the note is written there (novox/hq ADR 0239). */
const forgeContainer = process.env.MESH_GITEA_CONTAINER || "gitea";
@@ -450,6 +451,47 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
return { created: await gitea.addComment(owner, repo, number, body) };
},
},
{
name: "gitea_commit_statuses",
description: "Read a commit's statuses — or a pull request's head's — as the forge keeps them: the newest of each context (mesh/merge-gate, mesh/repo-check, mesh/delivery, …) with its state, description, link, when it was set and by whom; and whether the branch it merges into would let it merge, every status that branch's protection requires being a success. Reads only.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
number: { type: "number", description: "a pull request's number: its head is read, and its base's protection" },
sha: { type: "string", description: "a commit, or the start of one, instead of a pull request" },
base: { type: "string", description: "with sha: the branch whose protection is read (default main)" },
},
run: async (args) => {
const owner = String(args.owner ?? "").trim(), repo = String(args.repo ?? "").trim();
const number = args.number === undefined || args.number === "" ? 0 : Number(args.number);
let ref = String(args.sha ?? "").trim();
let base = String(args.base ?? "").trim();
if (!owner || !repo) throw new Error("name the repository: owner and repo");
if (!Number.isInteger(number) || number < 0) throw new Error(`${args.number} is not a pull request's number`);
let pull: { number: number; title: string; state: string; merged: boolean; base?: string; head?: string;
head_sha?: string; merge_commit_sha?: string; html_url: string } | undefined;
if (number > 0) {
const p = await gitea.getPullRequest(owner, repo, number);
pull = { number: p.number, title: p.title, state: p.state, merged: p.merged, base: p.base, head: p.head,
head_sha: p.head_sha, merge_commit_sha: p.merge_commit_sha, html_url: p.html_url };
if (!ref) ref = p.head_sha ?? "";
if (!base) base = p.base ?? "";
if (!ref) throw new Error(`${owner}/${repo}#${number} names no head commit`);
}
if (!ref) throw new Error("name a pull request's number or a commit");
const combined = await gitea.combinedStatus(owner, repo, ref);
const statuses = newestByContext(combined.statuses);
base = base || "main";
let rule: BranchProtection | null = null, unreadable: string | undefined;
try {
rule = await gitea.branchProtection(owner, repo, base);
} catch (err) {
unreadable = err instanceof Error ? err.message : String(err);
}
return { commit: combined.sha || ref, pull, combined: combined.state, statuses,
merge: mergeableUnder(base, rule, statuses, unreadable) };
},
},
{
name: "gitea_commit_status",
description: "Set one of the mesh's statuses on a commit (mesh/delivery, mesh/delivery-group): pending, success, error, failure or warning, a short description, and the page it links to.",