Every module built from a repository was rebuilt for a change to any of them: one merge in this repository meant twenty-six builds, which is what exhausted a public registry's pull limit. The forge lists the files a merge changed and the event carries them, from the watcher and from the merge tool alike; a merge that changed more files than were asked for says so, and the mesh then treats the whole repository as changed rather than guessing.
153 lines
7.1 KiB
TypeScript
153 lines
7.1 KiB
TypeScript
// 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<string>();
|
|
let primed = false;
|
|
async function pollRepos(client: GiteaClient): Promise<void> {
|
|
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<string>();
|
|
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<void> {
|
|
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<void>, 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");
|
|
}
|