From 01d2ff7a46bceb51412ffc26ce48eb11e5187695 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 12 Oct 2026 20:57:14 +0200 Subject: [PATCH] Announce a merged pull request's branch, not its pull ref, so its delivery group is found (hq issue 578) The merge tool deletes the branch after merging; the forge then reads head.ref as refs/pull//head and only head.label names the branch. pull.merged carried the pull ref, so the controller found no group for the merge, cut mesh-catalog #251's walk at once and walked it before mesh-controller #271's, against the group's order. --- modules/gitea/client.ts | 19 +++++++++++- modules/gitea/test/announcing.test.ts | 44 +++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 7219b84d..6685908f 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -49,6 +49,23 @@ export interface GiteaIssue { body?: string; } +/** + * **The branch a pull request was opened from, also once it is deleted** (novox/hq issue 578). The forge says it as + * `head.ref` while the branch exists; once it is gone — the merge tool deletes it after the merge by default — + * `head.ref` reads `refs/pull//head`, and only `head.label` still names the branch. A delivery group is the pull + * requests sharing a head branch name (novox/hq ADR 0249), and the controller finds a merge's group, and joins the + * group's merges in one batch, by the head its `pull.merged` names (ADR 0276 decision 5, ADR 0306 decision 2). With + * the pull ref it found no group: on 2026-10-12 mesh-catalog #251's walk was cut at once and went before + * mesh-controller #271's, which the group's order put first. + */ +export function headBranchOf(head: { ref?: unknown; label?: unknown } | null | undefined): string | undefined { + const said = (v: unknown): string | undefined => (typeof v === "string" && v !== "" ? v : undefined); + const ref = said(head?.ref); + const label = said(head?.label); + if (ref === undefined || /^refs\/pull\/\d+\/head$/.test(ref)) return label ?? ref; + return ref; +} + /** A pull request, trimmed to the fields a reviewer or an event body needs. */ export interface GiteaPull { number: number; @@ -673,7 +690,7 @@ export class GiteaClient { merge_commit_sha: p.merge_commit_sha ?? undefined, merged_at: p.merged_at ?? undefined, user: p.user?.login, - head: p.head?.ref, + head: headBranchOf(p.head), head_sha: p.head?.sha ?? undefined, base: p.base?.ref, updated_at: p.updated_at ?? undefined, diff --git a/modules/gitea/test/announcing.test.ts b/modules/gitea/test/announcing.test.ts index 19ab6f8a..b24c7fef 100644 --- a/modules/gitea/test/announcing.test.ts +++ b/modules/gitea/test/announcing.test.ts @@ -93,3 +93,47 @@ test("a pull request or issue number that is not one is refused before the forge server.close(); } }); + +// **A merge names the branch it came from, also once that branch is deleted** (novox/hq issue 578). On 2026-10-12 +// the merge tool merged mesh-controller #271 and mesh-catalog #251, one delivery group, and deleted each branch. The +// forge then read each pull request's head.ref as `refs/pull//head`, and `pull.merged` named that as the head: the +// controller asked mesh-delivery for a group of that name, found none, and cut #251's walk at once, before #271's, +// which the group's order put first. +test("a merged pull request whose branch was deleted is announced with its branch, not its pull ref", async () => { + const { GiteaClient, headBranchOf } = await import("../client.ts"); + const group = "feat/341-a-merge-check-judges-the-merge"; + // As the forge answered for #251 after its merge. + const merged = { + number: 251, title: "A verdict goes out of date when its trunk moves", state: "closed", merged: true, + merged_at: "2026-10-12T16:11:29Z", merge_commit_sha: "23ea0f431faf91aa83e7b0297e0a9e442180f0c7", + head: { label: group, ref: "refs/pull/251/head", sha: "41b6e10077efdef694b519879fcbaeaf69d472b1" }, + base: { label: "main", ref: "main" }, html_url: "http://fake/novox/mesh-catalog/pulls/251", + }; + const server = createServer((req, res) => { + const url = req.url ?? ""; + const send = (v: unknown) => res.end(JSON.stringify(v)); + if (url.startsWith("/api/v1/repos/novox/mesh-catalog/pulls/251")) return send(merged); + if (url.startsWith("/api/v1/repos/novox/mesh-catalog/pulls?")) return send([merged]); + res.statusCode = 404; + res.end("{}"); + }); + await new Promise((r) => server.listen(0, r)); + const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t"); + try { + // What the announcer reads each pass, and what `pull.merged` carries as its head. + const listed = await client.listPullRequests("novox", "mesh-catalog", { state: "closed", sort: "recentupdate", limit: "20" }); + assert.deepEqual(listed.map((p) => p.head), [group], "the pass names the group's branch, never refs/pull/251/head"); + assert.equal((await client.getPullRequest("novox", "mesh-catalog", 251)).head, group); + } finally { + server.close(); + } + // While the branch exists the forge says it as ref, and that is what is named; a label alone is the branch too. + assert.equal(headBranchOf({ label: "feat/x", ref: "feat/x" }), "feat/x"); + assert.equal(headBranchOf({ label: "feat/x" }), "feat/x"); + assert.equal(headBranchOf({ ref: "feat/x" }), "feat/x"); + // A branch whose own name only looks like a pull ref's start is still a branch. + assert.equal(headBranchOf({ label: "y", ref: "refs/pulled" }), "refs/pulled"); + // No label to read: the pull ref is all the forge said, and it is kept rather than made up. + assert.equal(headBranchOf({ ref: "refs/pull/7/head" }), "refs/pull/7/head"); + assert.equal(headBranchOf(undefined), undefined); +});