// gitea's events. The tool runtime imports this once the broker is bound. It watches the forge and // emits what appeared. // // Emits (novox/hq ADR 0041/0042): // module.gitea.repo.created — a repository appeared, however it was made (push, web UI, or tool) // // issue.opened and pull.merged are emitted from the tools (tools/index.ts), at the instant the mesh // takes that action — the natural point, and one process only. repo.created belongs here instead: // a repository is usually born from a `git push` or the web UI, which no tool sees, so polling the // repo list is the only way to catch every path — and keeping it out of the create-repo tool means // the fact is never announced twice from two processes. // // The polling is deliberately unhurried: an event a minute late is still an event, whereas hammering // the forge for an immediacy nobody asked for is not. import { emit } from "@novox/mesh-sdk/events"; import { GiteaClient } from "./client.js"; // Without a way to a token — configured, or mintable with the admin account (token.ts) — there is // nothing to watch; log and stay quiet rather than crash the runtime. With one, the first poll mints // or reuses the token, so the runtime's start also shows what it did about it. let gitea: GiteaClient | null = null; try { gitea = GiteaClient.fromEnv(); } catch (err) { console.log(`[gitea] not watching — ${err instanceof Error ? err.message : String(err)}`); } // New repositories, by diffing the repo list. Primed silently on the first look, or a restart would // re-announce every existing repository as freshly created. const seen = new Set(); let primed = false; async function pollRepos(client: GiteaClient): Promise { const repos = await client.listAllRepos(); for (const repo of repos) { if (!seen.has(repo.full_name)) { if (primed) { await emit("repo.created", { full_name: repo.full_name, owner: repo.owner, name: repo.name, private: repo.private, html_url: repo.html_url, }); } seen.add(repo.full_name); } } 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; // since is the moment the watching began: a merge made before it is history, whatever page of the // forge's listing it surfaces on. Without it, an old merge past the first page — pushed into view // as newer pull requests were updated — was announced as if it had just happened, and the mesh // rebuilt everything built from that repository, once per old merge (2026-09-28). let since = ""; if (mergedRecord && existsSync(mergedRecord)) { try { const kept = JSON.parse(readFileSync(mergedRecord, "utf8")) as string[] | { announced: string[]; since: string }; const list = Array.isArray(kept) ? kept : kept.announced; for (const sha of list) announced.add(sha); since = Array.isArray(kept) ? new Date().toISOString() : kept.since; 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: [...announced].slice(-2000), since })); renameSync(tmp, mergedRecord); } async function pollMerged(client: GiteaClient): Promise { const repos = await client.listAllRepos(); 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; // Announced only if merged since the watching began; recorded either way, so it is looked // at once. const fresh = !!pull.merged_at && !!since && pull.merged_at > since; if (primedMerges && fresh) { // What it changed, asked for only now: a module is rebuilt because a file inside its own // directory moved, and without this every module built from a repository is rebuilt for a // change to any of them (novox/hq 04-ISSUES/131). const changed = await client.listPullFiles(repo.owner, repo.name, pull.number); 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, paths: changed.paths, paths_truncated: changed.truncated, }); // Said, because a trigger that fires silently is indistinguishable from one that did not // fire (novox/hq 04-ISSUES/131) — this line is how an operator knows the mesh was told. console.log(`[gitea] announced merge ${repo.full_name}#${pull.number} (${pull.merge_commit_sha.slice(0, 8)}) into ${pull.base}`); } announced.add(pull.merge_commit_sha); changed = true; } } if (!primedMerges) since = new Date().toISOString(); 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 // yet, the admin account refused on a restored forge) is one fact, and a recovery is worth a line. let failing: string | null = null; const tick = (fn: () => Promise, everyMs: number): void => { const run = (): void => void fn() .then(() => { if (failing !== null) console.log("[gitea] watching again"); failing = null; }) .catch((err) => { const why = err instanceof Error ? err.message : String(err); if (why !== failing) console.error(`[gitea] not watching until this clears — ${why}`); failing = why; }); setInterval(run, everyMs); run(); }; tick(() => pollRepos(client), 60_000); tick(() => pollMerged(client), 30_000); console.log("[gitea] watching for new repositories and merged pull requests"); }