From eb62289f89b6920ec216896723f3e2dab97fdb52 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 28 Sep 2026 02:54:48 +0200 Subject: [PATCH] The forge announces every merge, whoever made it The merge tool emitted at the instant it acted; a merge made in the forge's own pages or over its API emitted nothing, and the mesh went on believing every module current with its source (novox/hq 04-ISSUES/131). Merged pull requests are now watched the way repositories are: what the forge holds, asked for on a tick, announced once, with the merge commit and the clone URL a build needs. What has been announced is kept beside the module's state, so a restart does not announce the whole history again, and a first tick with no record announces nothing. --- modules/gitea/client.ts | 8 +++++ modules/gitea/index.ts | 59 +++++++++++++++++++++++++++++++++++- modules/gitea/tools/index.ts | 4 +++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 06cbb7d..09f05d0 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -9,6 +9,8 @@ import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js"; /** A repository, trimmed to what the mesh cares about. */ export interface GiteaRepo { full_name: string; + /** The URL a build clones — what a module records as its source. */ + clone_url?: string; name: string; owner: string; private: boolean; @@ -34,6 +36,9 @@ export interface GiteaPull { title: string; state: string; merged: boolean; + /** The commit the merge produced — what a build of the base branch is made from. */ + merge_commit_sha?: string; + merged_at?: string; user?: string; head?: string; base?: string; @@ -232,6 +237,7 @@ export class GiteaClient { private static mapRepo(r: any): GiteaRepo { return { full_name: r.full_name, + clone_url: r.clone_url ?? undefined, name: r.name, owner: r.owner?.login ?? r.full_name?.split("/")[0] ?? "unknown", private: Boolean(r.private), @@ -259,6 +265,8 @@ export class GiteaClient { title: p.title, state: p.state, merged: Boolean(p.merged), + merge_commit_sha: p.merge_commit_sha ?? undefined, + merged_at: p.merged_at ?? undefined, user: p.user?.login, head: p.head?.ref, base: p.base?.ref, diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index eb1d565..ccc3ab4 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -49,6 +49,62 @@ async function pollRepos(client: GiteaClient): Promise { primed = true; } +// **A merge is announced whoever made it.** The merge tool below emits at the instant it acts; a +// merge made in the forge's own pages or over its API would emit nothing, and the mesh would go on +// believing every module current with its source (novox/hq 04-ISSUES/131). So merged pull requests +// are watched the way repositories are: what the forge holds, asked for on a tick, announced once. +// What has been announced is kept beside the module's state, so a restart does not announce the +// whole history again — and the first tick on a machine with no record announces nothing, because +// everything it sees then predates the watching. +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +const mergedRecord = process.env.MESH_GITEA_STATE_DIR ? join(process.env.MESH_GITEA_STATE_DIR, "merged-announced.json") : null; +const announced = new Set(); +let primedMerges = false; +if (mergedRecord && existsSync(mergedRecord)) { + try { + for (const sha of JSON.parse(readFileSync(mergedRecord, "utf8")) as string[]) announced.add(sha); + primedMerges = true; + } catch { + // An unreadable record is treated as no record: prime again rather than re-announce history. + } +} +function keepAnnounced(): void { + if (!mergedRecord) return; + mkdirSync(join(mergedRecord, ".."), { recursive: true }); + const tmp = mergedRecord + ".tmp"; + writeFileSync(tmp, JSON.stringify([...announced].slice(-2000))); + renameSync(tmp, mergedRecord); +} +async function pollMerged(client: GiteaClient): Promise { + const repos = await client.listRepos(1, 50); + let changed = false; + for (const repo of repos) { + const pulls = await client.listPullRequests(repo.owner, repo.name, { state: "closed", sort: "recentupdate", limit: "20" }); + for (const pull of pulls) { + if (!pull.merged || !pull.merge_commit_sha || announced.has(pull.merge_commit_sha)) continue; + if (primedMerges) { + await emit("pull.merged", { + owner: repo.owner, + repo: repo.name, + number: pull.number, + title: pull.title, + head: pull.head, + base: pull.base, + merge_commit_sha: pull.merge_commit_sha, + merged_at: pull.merged_at, + clone_url: repo.clone_url, + html_url: pull.html_url, + }); + } + announced.add(pull.merge_commit_sha); + changed = true; + } + } + if (!primedMerges || changed) keepAnnounced(); + primedMerges = true; +} + if (gitea) { const client = gitea; // A poll that fails says so once, not once a minute: the same reason repeating (the forge not up @@ -70,5 +126,6 @@ if (gitea) { run(); }; tick(() => pollRepos(client), 60_000); - console.log("[gitea] watching for new repositories"); + tick(() => pollMerged(client), 30_000); + console.log("[gitea] watching for new repositories and merged pull requests"); } diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index e910f8b..103d4a9 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -231,6 +231,8 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] { // Read the PR first, so the merged event carries a title and branches, not just a number. const pull = await gitea.getPullRequest(owner, repo, number); await gitea.mergePullRequest(owner, repo, number, method, deleteBranch); + // Read it again: the merge commit only exists now, and it is what a build is made from. + const merged = await gitea.getPullRequest(owner, repo, number); await emit("pull.merged", { owner, repo, @@ -238,6 +240,8 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] { title: pull.title, head: pull.head, base: pull.base, + merge_commit_sha: merged.merge_commit_sha, + merged_at: merged.merged_at, method, html_url: pull.html_url, });