Merge a pull request marked ready at its head once its checks pass (issue 722)
mesh/merge-gate out of date: judged against trunk 482232e8c51c, the trunk is now 7e3e62062bef; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 482232e8c51c, the trunk is now 7e3e62062bef; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request

The operator wants a reviewed pull request merged without a session calling
the merge tool again after every recheck. The reviewer marks it ready (label
mesh/ready and a comment "ready at <full head sha>"); the forge's open-pulls
read carries the mark, and mesh-delivery merges through gitea_merge_pull_request
at that head, in a group's order once its composed check passed. A push after
the mark clears it; a failed check is said on the pull request and as a
stalled line.
This commit is contained in:
2026-10-14 21:23:26 +02:00
parent 482232e8c5
commit 7e0001e304
15 changed files with 688 additions and 10 deletions
+38 -4
View File
@@ -101,6 +101,34 @@ export interface OpenPull {
/** The head's statuses by context, the newest of each, with its state (novox/hq issue 438): what tells a check
* that started and never answered — pending — from one that answered. */
checks: Record<string, HeadCheck>;
/** The pull request's labels, by name. */
labels?: string[];
/** Its ready mark (novox/hq issue 722): the label `mesh/ready` and the newest comment "ready at <full head sha>", the
* head a reviewer approved. Absent without the label; `head` empty when the label names no head in a comment. */
ready?: ReadyMark;
}
/** The label that marks a pull request ready to merge at the head a comment names (novox/hq issue 722). */
export const READY_LABEL = "mesh/ready";
/** A comment that names the head a pull request is marked ready at: "ready at <full sha>", on a line of its own. */
const READY_AT = /^\s*ready at ([0-9a-f]{40}|[0-9a-f]{64})\b/im;
/** A pull request's ready mark: the head it names, who said it and when, and the label's id, to take it off. */
export interface ReadyMark {
head: string;
by?: string;
at?: string;
label_id: number;
}
/** The ready mark a pull request's comments make, oldest first as the forge lists them: the newest that names a head. */
export function readyMarkOf(labelID: number, comments: GiteaComment[]): ReadyMark {
for (let i = comments.length - 1; i >= 0; i--) {
const m = READY_AT.exec(comments[i].body);
if (m) return { head: m[1].toLowerCase(), by: comments[i].user, at: comments[i].created_at, label_id: labelID };
}
return { head: "", label_id: labelID };
}
/** One status on a head, the newest of its context: its state, what it said, and when it was set. */
@@ -332,8 +360,9 @@ export class GiteaClient {
/** The open pull requests of every repository the mesh's account sees, updated since a moment when one is given,
* page by page (novox/hq issue 347): each with its repository and its own update time. */
async searchOpenPulls(since?: string): Promise<{ owner: string; name: string; number: number; updated_at?: string }[]> {
const out: { owner: string; name: string; number: number; updated_at?: string }[] = [];
async searchOpenPulls(since?: string): Promise<{ owner: string; name: string; number: number; updated_at?: string;
labels?: { id: number; name: string }[] }[]> {
const out: { owner: string; name: string; number: number; updated_at?: string; labels?: { id: number; name: string }[] }[] = [];
for (let page = 1; page <= 20; page++) {
const qs = new URLSearchParams({ type: "pulls", state: "open", limit: "50", page: String(page) });
if (since) qs.set("since", since);
@@ -343,7 +372,8 @@ export class GiteaClient {
const [fo, fn] = String(r.full_name ?? "").split("/");
const owner = String(r.owner ?? fo ?? "");
const name = String(r.name ?? fn ?? "");
if (owner && name && i?.number) out.push({ owner, name, number: Number(i.number), updated_at: i.updated_at });
const labels = (Array.isArray(i?.labels) ? i.labels : []).map((l: any) => ({ id: Number(l?.id ?? 0), name: String(l?.name ?? "") }));
if (owner && name && i?.number) out.push({ owner, name, number: Number(i.number), updated_at: i.updated_at, labels });
}
if (!found || found.length < 50) break;
}
@@ -364,9 +394,13 @@ export class GiteaClient {
if (!rules.has(full)) rules.set(full, await this.branchProtections(found.owner, found.name).catch(() => undefined));
const rule = rules.get(full)?.find((p) => (p.rule_name ?? p.branch_name) === (pull.base || "main"));
const checks = await this.headChecks(found.owner, found.name, pull.head_sha);
const labels = found.labels ?? [];
// The ready mark (novox/hq issue 722): its comments are read only for a pull request that carries the label.
const label = labels.find((l) => l.name === READY_LABEL);
const ready = label ? readyMarkOf(label.id, await this.listComments(found.owner, found.name, pull.number)) : undefined;
out.push({ owner: found.owner, repo: found.name, number: pull.number, base: pull.base ?? "", head_sha: pull.head_sha,
html_url: pull.html_url, required: rule?.enable_status_check ? (rule.status_check_contexts ?? []) : [],
statuses: Object.keys(checks), checks });
statuses: Object.keys(checks), checks, labels: labels.map((l) => l.name), ...(ready ? { ready } : {}) });
}
return out;
}
+49
View File
@@ -137,3 +137,52 @@ test("a merged pull request whose branch was deleted is announced with its branc
assert.equal(headBranchOf({ ref: "refs/pull/7/head" }), "refs/pull/7/head");
assert.equal(headBranchOf(undefined), undefined);
});
// **A pull request marked ready says the head it is marked at** (novox/hq issue 722): the label mesh/ready and the
// newest comment "ready at <full head sha>". mesh-delivery merges on it; without the label no comment is read.
test("a pull request's ready mark is read: the label and the head the newest ready comment names", async () => {
const { GiteaClient } = await import("../client.ts");
const head = "c".repeat(40), older = "d".repeat(40);
const asked: string[] = [];
const server = createServer((req, res) => {
const url = req.url ?? "";
asked.push(url);
const send = (v: unknown) => res.end(JSON.stringify(v));
if (url.startsWith("/api/v1/repos/issues/search")) {
return send(url.includes("page=1") ? [
{ number: 7, repository: { owner: "novox", name: "hq", full_name: "novox/hq" },
labels: [{ id: 31, name: "mesh/ready" }, { id: 2, name: "bug" }] },
{ number: 8, repository: { owner: "novox", name: "hq", full_name: "novox/hq" }, labels: [] },
] : []);
}
if (url.startsWith("/api/v1/repos/novox/hq/pulls/7")) {
return send({ number: 7, state: "open", base: { ref: "main" }, head: { ref: "x", sha: head }, html_url: "h" });
}
if (url.startsWith("/api/v1/repos/novox/hq/pulls/8")) {
return send({ number: 8, state: "open", base: { ref: "main" }, head: { ref: "y", sha: older }, html_url: "h" });
}
if (url.startsWith("/api/v1/repos/novox/hq/issues/7/comments")) {
return send([
{ id: 1, user: { login: "mesh" }, body: `ready at ${older}`, created_at: "2026-10-14T10:00:00Z" },
{ id: 2, user: { login: "mesh" }, body: `Reviewed: approve.\nready at ${head}\n`, created_at: "2026-10-14T11:00:00Z" },
{ id: 3, user: { login: "jochen" }, body: "thanks", created_at: "2026-10-14T12:00:00Z" },
]);
}
if (url.includes("/branch_protections")) return send([]);
if (url.includes("/status")) return send({ statuses: [] });
res.statusCode = 404;
res.end("{}");
});
await new Promise<void>((r) => server.listen(0, r));
const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t");
try {
const open = await client.openPulls();
assert.deepEqual(open[0].labels, ["mesh/ready", "bug"]);
assert.deepEqual(open[0].ready, { head, by: "mesh", at: "2026-10-14T11:00:00Z", label_id: 31 },
"the newest comment that names a head");
assert.equal(open[1].ready, undefined, "no label, no mark");
assert.ok(!asked.some((u) => u.includes("/issues/8/comments")), "no comment read without the label");
} finally {
server.close();
}
});
+1 -1
View File
@@ -514,7 +514,7 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] {
},
{
name: "gitea_open_pulls",
description: "Every open pull request of every repository, from the forge itself: its head, its base, the statuses the base's protection requires and the ones its head has, each with its state, description and when it was set. What mesh-delivery reads to say a pull request the mesh was never asked to check (novox/hq issue 347), or whose check started and never answered (issue 438). Reads only.",
description: "Every open pull request of every repository, from the forge itself: its head, its base, the statuses the base's protection requires and the ones its head has, each with its state, description and when it was set. What mesh-delivery reads to say a pull request the mesh was never asked to check (novox/hq issue 347), or whose check started and never answered (issue 438), and each pull request's labels and ready mark — the label mesh/ready and the head the newest comment \"ready at <full head sha>\" names — which mesh-delivery merges on (issue 722). Reads only.",
input: {},
run: async () => ({ pulls: await gitea.openPulls() }),
},