// 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) // module.gitea.pull.merged — a pull request was merged, however it was merged (web UI, API, or tool) // module.gitea.pull.updated — an open pull request's head moved, opened or pushed to: the mesh checks it // before it merges (novox/hq to-be 45 §9) // // module.gitea.pull.closed — a pull request closed unmerged: the delivery it was is stopped (novox/hq ADR 0239) // // Consumes mesh-controller.checked — a pull request's merge check, judged — and sets it as the head // commit's statuses: `mesh/merge-gate`, the modules of the mesh's graph the change touches, and // `mesh/repo-check`, the repository's own merge-check.sh (novox/hq ADR 0237 as amended); with a comment // saying why when the gate is not a pass or the repository's own check failed. // // Consumes node-build-agent.started — a build machine took an ask — and says, on the head a pending check is of, // which machine builds it since when (novox/hq issue 446). This module is the one writer of mesh/merge-gate. // // **A trunk's move puts every open verdict into it out of date** (novox/hq ADR 0323 §4, trunk.ts): each pass reads the // head of every trunk an open pull request merges into, and when one moved, the merge check statuses of every open pull // request into it go back to pending with the reason. Nothing is run for it. A verdict that lands judged against a trunk // commit that is no longer the trunk's head is said out of date in place of its pass or failure. // // Serves gitea_commit_status and gitea_merge_pull_request from this bundle, not the tools', so a recheck's pending and // the merge tool's ask of a check reach that one writer (novox/hq issue 456, ADR 0323). // // Every pull request the forge holds is announced, whatever its repository: the controller holds the // module graph and decides what is checked — a repository is never asked to opt in. // // issue.opened is emitted from its tool (tools/index.ts). repo.created and pull.merged belong here: a // repository or a merge is as often made by the web UI or a plain API call, which no tool sees, so // polling is the only way to catch every path — and the only emitter, so a fact is never announced // twice. The merge tool announced too until novox/hq issue 250, and every merge it made was heard twice. // // 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, on } from "@novox/mesh-sdk/events"; import { registerModuleTools } from "@novox/mesh-sdk/tools"; import { commitStatusTool } from "./delivery.js"; import { mergeTool } from "./merge.js"; import { GiteaClient, movedSince } from "./client.js"; import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, headsToAnnounce, statusesFor, type Announced, type Checked, type RepoCheckFacts, reposToLook } from "./pulls.js"; import { GateWriter, clonedAs, type BuildStarted } from "./gate.js"; import { announcementOf, keepTook, lateVerdictComment, outOfDateWords, sameCommit, tookFile, weighTrunks } from "./trunk.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); } // **Only the repositories that moved** (novox/hq issue 250). A merge is a push, and a push moves the // repository's update time; asking every repository for its pull requests on every tick took longer than the // tick itself, so ticks piled up and a merge was announced minutes late. A repository unchanged since a // minute before the last look is skipped — the minute absorbs the forge's clock against this one. let lastLook = ""; const MARGIN_MS = 60_000; async function pollMerged(client: GiteaClient): Promise { const began = new Date().toISOString(); const floor = lastLook && primedMerges ? new Date(Date.parse(lastLook) - MARGIN_MS).toISOString() : ""; const repos = movedSince(await client.listAllRepos(), floor); 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) { // Closed unmerged (novox/hq ADR 0239): announced once, since the watching began, so its delivery stops. const closedKey = `closed:${repo.full_name}#${pull.number}`; if (!pull.merged && pull.state === "closed" && !announced.has(closedKey)) { if (primedMerges && !!pull.updated_at && !!since && pull.updated_at > since) { await emit("pull.closed", { owner: repo.owner, repo: repo.name, number: pull.number, title: pull.title, head: pull.head, head_sha: pull.head_sha, base: pull.base, html_url: pull.html_url }); console.log(`[gitea] announced ${repo.full_name}#${pull.number} closed unmerged`); } announced.add(closedKey); changed = true; continue; } 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); // Which of the directories they are in hold a module at the merge commit (novox/hq issue 278): a // change inside one is that module's, held or not, and only a file in none is shared code. Not // said when the list is cut or the forge could not be asked; the mesh then keeps its old rule. let moduleDirs: string[] | null = null; if (!changed.truncated) { try { moduleDirs = await client.moduleDirsAt(repo.owner, repo.name, pull.merge_commit_sha, changed.paths); } catch (err) { console.error(`[gitea] ${repo.full_name}#${pull.number}: which directories hold a module could not be read, ` + `so the mesh reads its files by the old rule — ${err instanceof Error ? err.message : String(err)}`); } } // The head it merged, and what its head was checked as (novox/hq ADR 0239): the delivery it was, made // from the forge's word when its owner never heard the head. let headChecks: Record | null = null; if (pull.head_sha) { try { headChecks = await client.commitStatuses(repo.owner, repo.name, pull.head_sha); } catch (err) { console.error(`[gitea] ${repo.full_name}#${pull.number}: its head's statuses could not be read — ${err instanceof Error ? err.message : err}`); } } await emit("pull.merged", { owner: repo.owner, repo: repo.name, number: pull.number, title: pull.title, body: pull.body, head_sha: pull.head_sha, ...(headChecks ? { head_checks: headChecks } : {}), 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, // Which of them the merge deleted (novox/hq ADR 0236): a module whose manifest went is forgotten, // not built. removed: changed.removed, ...(moduleDirs ? { module_dirs: moduleDirs, module_dirs_said: true } : {}), }); // 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; lastLook = began; } // **Every new head of an open pull request is announced, once** (novox/hq to-be 45 §9): the mesh checks it // against every machine of its facts before it merges. Kept beside the merges' record, so a restart // announces nothing twice; the first look on a machine with no record announces only what moved in the // last day, so a forge's whole backlog is not checked at once. const pullsRecord = process.env.MESH_GITEA_STATE_DIR ? join(process.env.MESH_GITEA_STATE_DIR, "pulls-announced.json") : null; let heads: Announced = {}; let primedPulls = false; if (pullsRecord && existsSync(pullsRecord)) { try { heads = (JSON.parse(readFileSync(pullsRecord, "utf8")) as { heads: Announced }).heads ?? {}; primedPulls = true; } catch { // An unreadable record is no record: the first look announces only the last day's. } } function keepHeads(): void { if (!pullsRecord) return; mkdirSync(join(pullsRecord, ".."), { recursive: true }); const tmp = pullsRecord + ".tmp"; writeFileSync(tmp, JSON.stringify({ heads })); renameSync(tmp, pullsRecord); } let lastPullLook = ""; // **A sweep now and then looks at every repository** (novox/hq issue 347), whatever moved: the floor is a // heuristic, and a heuristic that misses is caught by the next sweep rather than never. const SWEEP_MS = 10 * 60_000; let lastSweep = 0; async function pollPulls(client: GiteaClient): Promise { const began = new Date().toISOString(); const floor = lastPullLook ? new Date(Date.parse(lastPullLook) - MARGIN_MS).toISOString() : ""; const dayAgo = new Date(Date.now() - 24 * 3600_000).toISOString(); let changed = false; // A pull request opened, reopened or retargeted moves its own update time and not its repository's: the // pull requests updated since the floor are found by their own (issue 347). A search that fails is a sweep. let sweep = Date.now() - lastSweep >= SWEEP_MS; let updated: { owner: string; name: string; updated_at?: string }[] = []; if (floor && !sweep) { try { updated = await client.searchOpenPulls(floor); } catch (err) { console.error(`[gitea] the pull requests updated lately could not be searched, so every repository is looked at — ${err instanceof Error ? err.message : err}`); sweep = true; } } if (sweep) lastSweep = Date.now(); for (const repo of reposToLook(await client.listAllRepos(), floor, updated, sweep)) { // Every open pull request updated since the floor, paged (novox/hq issue 579): the newest 20 alone left a head // beyond them unannounced. A sweep and the first pass read every page. const open = await client.listOpenPullsSince(repo.owner, repo.name, sweep ? "" : floor); for (const pull of headsToAnnounce(repo.full_name, open, heads)) { const key = `${repo.full_name}#${pull.number}`; const fresh = primedPulls || (!!pull.updated_at && pull.updated_at > dayAgo); if (fresh) { // The announcement the merge tool makes too when it asks a check (trunk.ts, novox/hq ADR 0323 §5). await emit("pull.updated", await announcementOf(client, repo.owner, repo.name, repo.clone_url, pull)); // Said, so an operator knows the mesh was asked to check it. console.log(`[gitea] announced ${key} at ${String(pull.head_sha).slice(0, 8)} to be checked before it merges`); // Pending until the verdict comes, so the pull request says a check is running rather than nothing — and // where it is: in the build queue from now, until a build machine takes it (novox/hq issue 446). await gates.pending(repo.owner, repo.name, String(pull.head_sha)); } heads[key] = String(pull.head_sha); changed = true; } if (await trunksMoved(client, repo.owner, repo.name)) changedTrunks = true; } if (!primedPulls || changed) keepHeads(); if (changedTrunks) keepTrunks(); changedTrunks = false; primedPulls = true; lastPullLook = began; } // **The trunks' heads, as this module last read them** (novox/hq ADR 0323 §4), by `owner/repo:branch`: kept beside the // other records, so a move made while this module was away is weighed on its first pass back. With no record — the // first pass after this rolls out — a trunk's head is taken as it is and nothing is put out of date: the verdicts // then standing are weighed on the trunk's next move, and the merge tool compares commits whatever this holds. const trunksRecord = process.env.MESH_GITEA_STATE_DIR ? join(process.env.MESH_GITEA_STATE_DIR, "trunks-seen.json") : null; let trunks: Record = {}; let changedTrunks = false; if (trunksRecord && existsSync(trunksRecord)) { try { trunks = (JSON.parse(readFileSync(trunksRecord, "utf8")) as { trunks: Record }).trunks ?? {}; } catch { // An unreadable record is no record: the next pass takes each trunk's head as it is. } } function keepTrunks(): void { if (!trunksRecord) return; mkdirSync(join(trunksRecord, ".."), { recursive: true }); const tmp = trunksRecord + ".tmp"; writeFileSync(tmp, JSON.stringify({ trunks })); renameSync(tmp, trunksRecord); } /** Whether a trunk of one repository moved, weighed with this module's forge and its one writer (trunk.ts). */ function trunksMoved(client: GiteaClient, owner: string, name: string): Promise { return weighTrunks(client, gates, trunks, owner, name); } // **The verdict, set where the pull request shows it.** Every verdict is the head commit's status; one // that is not a pass also leaves the check's own account as a comment, so the reason is read where the // change is reviewed. An error — the check could not run — is the forge's `error`, never a success. async function setVerdict(client: GiteaClient, event: { body: unknown }): Promise { const c = (event.body ?? {}) as Checked; if (!c.group && c.owner && c.repo && c.took) { // How long it took, for the merge tool to say how long the next one may (novox/hq ADR 0323 §5). keepTook(tookFile(process.env.MESH_GITEA_STATE_DIR), `${c.owner}/${c.repo}`, { took: c.took, at: new Date().toISOString(), head: c.commit }); } if (c.group) { // A delivery group's composed check (novox/hq ADR 0239): its verdict is the group owner's to say, on each // member's head, as mesh/delivery-group — never this head's merge gate. return; } if (!c.owner || !c.repo || !c.commit || !c.verdict) { console.error("[gitea] a merge check's verdict named no repository, commit or verdict; ignored"); return; } // Each status links to the pull request, where the delivery's view is kept (novox/hq ADR 0239). let target: string | undefined; let base: string | undefined; if (c.number) { const pull = await client.getPullRequest(c.owner, c.repo, c.number).catch(() => undefined); target = pull?.html_url || undefined; base = pull?.base || undefined; } // **A verdict judged against a trunk commit that is no longer the trunk's head is out of date when it lands** // (novox/hq ADR 0323 §4): a check asked before the trunk moved. It is said so, pending with the reason, in place of // its pass or failure; a trunk that cannot be read lets the verdict stand, and the merge tool compares the commits. const trunk = base ?? c.plan?.base; if (c.trunk && trunk) { const head = await client.branchHead(c.owner, c.repo, trunk).catch(() => undefined); if (head && !sameCommit(c.trunk, head)) { const description = outOfDateWords(c.trunk, head); for (const context of [CHECK_CONTEXT, ...(c["repo-check"] ? [REPO_CHECK_CONTEXT] : [])]) { await client.setCommitStatus(c.owner, c.repo, c.commit, { state: "pending", context, description, ...(target ? { target_url: target } : {}) }); } // Its account is kept and shown all the same (ADR 0323 §7): what it judged, and that it is out of date. const said = commentFor(c, await repoCheckFacts(client, c, trunk)); if (c.number) await client.addComment(c.owner, c.repo, c.number, lateVerdictComment(c, said, description)); console.log(`[gitea] ${c.owner}/${c.repo}#${c.number ?? "?"} at ${c.commit.slice(0, 8)}: merge check ${c.verdict}, ` + `${description}`); return; } } const facts = await repoCheckFacts(client, c, base ?? c.plan?.base); for (const status of statusesFor(c, facts)) { await client.setCommitStatus(c.owner, c.repo, c.commit, target ? { ...status, target_url: target } : status); } const comment = commentFor(c, facts); if (comment && c.number) await client.addComment(c.owner, c.repo, c.number, comment); console.log(`[gitea] ${c.owner}/${c.repo}#${c.number ?? "?"} at ${c.commit.slice(0, 8)}: merge check ${c.verdict}`); } // **What only the forge knows of a repository check said as a warning** (novox/hq issue 293): whether the // head holds a merge-check.sh at all, and — when it does not — whether the base branch's protection // requires mesh/repo-check. Asked only for a warning; unknown is left undefined, which statusesFor reads // as possibly required: a failure on a status nothing requires blocks nothing, a success on one that is // required would let an untested repository merge. async function repoCheckFacts(client: GiteaClient, c: Checked, base: string | undefined): Promise { if (c["repo-check"]?.verdict !== "warning") return {}; const defined = await client.holdsFile(c.owner, c.repo, c.commit, "merge-check.sh").catch(() => undefined); if (defined !== false) return { defined }; const rules = await client.branchProtections(c.owner, c.repo).catch(() => undefined); if (!rules) return { defined }; const rule = rules.find((p) => (p.rule_name ?? p.branch_name) === (base || "main")); const required = !!rule?.enable_status_check && (rule.status_check_contexts ?? []).includes(REPO_CHECK_CONTEXT); return { defined, required }; } // **This module is the one writer of a head's mesh/merge-gate** (novox/hq issue 446): the announcement's pending words, // where the check is when a build machine takes it, and the verdict — each head's writes in one chain, the verdict last // (gate.ts). A second writer elsewhere raced the verdict and could leave a pass reading pending. const gates = new GateWriter({ setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status), gateOf: async (owner, repo, sha) => (await (gitea as GiteaClient).headChecks(owner, repo, sha))[CHECK_CONTEXT], gateHistory: (owner, repo, sha) => (gitea as GiteaClient).contextHistory(owner, repo, sha, CHECK_CONTEXT), headChecks: (owner, repo, sha) => (gitea as GiteaClient).headChecks(owner, repo, sha), }); // **The status tool is served here, beside the writer** (novox/hq issue 456): the runtime starts each bundle as its own // process, so a recheck's pending written from the tools' bundle bypassed this writer's chain. Without a forge to // reach, nothing is served, as the tools' bundle serves nothing then. registerModuleTools("gitea", () => (gitea ? [commitStatusTool({ setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status), gates, }), mergeTool(gitea, { gates })] : [])); // **A build machine took an ask** (the build seat's `started`, novox/hq ADR 0157): said on the head it checks, when // the head is an open pull request's this module announced. Any other build — a branch, a merge's — says nothing. async function buildStarted(event: { body: unknown }): Promise { const b = (event.body ?? {}) as BuildStarted; const ref = String(b.ref ?? "").toLowerCase(); if (!/^[0-9a-f]{40}$/.test(ref)) return; for (const [key, sha] of Object.entries(heads)) { if (sha.toLowerCase() !== ref) continue; const [owner, rest] = key.split("/"); const repo = rest?.split("#")[0] ?? ""; if (owner && repo && clonedAs(b.repository, owner, repo)) { await gates.started(owner, repo, sha, b); return; } } } 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 => { // **One pass at a time** (novox/hq issue 250): the next pass is scheduled when this one has ended, so a // pass that outlasts its interval delays the next instead of running beside it — two passes at once // could each announce the same merge before either recorded it. 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; }) .finally(() => setTimeout(run, everyMs)); run(); }; tick(() => pollRepos(client), 60_000); tick(() => pollMerged(client), 30_000); tick(() => pollPulls(client), 30_000); await on("mesh-controller.checked", (event) => { const c = (event.body ?? {}) as Checked; // A head's own verdict takes its turn after every write of that head's gate already asked for (gate.ts). // A verdict of a check asked before the one now pending is an older run's, and dropped (novox/hq issue 456). if (!c.group && c.owner && c.repo && c.commit) { return gates.verdict(c.owner, c.repo, c.commit, () => setVerdict(client, event), c.id); } return setVerdict(client, event); }); await on("node-build-agent.started", buildStarted); console.log("[gitea] watching for new repositories and merged pull requests"); }