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:
@@ -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 };
|
||||
}
|
||||
@@ -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 }) });
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user