One module answers 'did my change go out' for a commit and orders a cross-repository change: one compiled state table, its state on the bus, every transition said, noted on the commit and shown on the pull request. The forge's holder gains the note, view and status tools it asks with, and says closed pull requests and a merge's head and statuses.
331 lines
17 KiB
TypeScript
331 lines
17 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)
|
|
// 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.
|
|
//
|
|
// 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 { GiteaClient, movedSince } from "./client.js";
|
|
import { CHECK_CONTEXT, commentFor, headsToAnnounce, statusesFor, type Announced, type Checked } from "./pulls.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);
|
|
}
|
|
// **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<void> {
|
|
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<string, string> | 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 = "";
|
|
async function pollPulls(client: GiteaClient): Promise<void> {
|
|
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;
|
|
for (const repo of movedSince(await client.listAllRepos(), floor)) {
|
|
const open = await client.listPullRequests(repo.owner, repo.name, { state: "open", sort: "recentupdate", limit: "20" });
|
|
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) {
|
|
const files = await client.listPullFiles(repo.owner, repo.name, pull.number);
|
|
const head = String(pull.head_sha);
|
|
// What the controller maps the change onto the mesh's module graph with (novox/hq ADR 0237 as
|
|
// amended): which changed directories hold a module at the head — the merge's rule (issue 278) —
|
|
// and whether the head holds the repository's own merge-check.sh. Not said when it could not be
|
|
// read; the controller then reads the change as touching everything built from the repository.
|
|
let moduleDirs: string[] | null = null;
|
|
let mergeCheck: boolean | null = null;
|
|
try {
|
|
if (!files.truncated) moduleDirs = await client.moduleDirsAt(repo.owner, repo.name, head, files.paths);
|
|
mergeCheck = await client.holdsFile(repo.owner, repo.name, head, "merge-check.sh");
|
|
} catch (err) {
|
|
console.error(`[gitea] ${key}: what the head holds could not be read — ${err instanceof Error ? err.message : err}`);
|
|
}
|
|
await emit("pull.updated", {
|
|
owner: repo.owner,
|
|
repo: repo.name,
|
|
number: pull.number,
|
|
title: pull.title,
|
|
body: pull.body,
|
|
base: pull.base,
|
|
head: pull.head,
|
|
head_sha: pull.head_sha,
|
|
clone_url: repo.clone_url,
|
|
html_url: pull.html_url,
|
|
paths: files.paths,
|
|
paths_truncated: files.truncated,
|
|
removed: files.removed,
|
|
...(moduleDirs ? { module_dirs: moduleDirs, module_dirs_said: true } : {}),
|
|
...(mergeCheck !== null ? { merge_check: mergeCheck, merge_check_said: true } : {}),
|
|
});
|
|
// 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.
|
|
await client
|
|
.setCommitStatus(repo.owner, repo.name, String(pull.head_sha), {
|
|
state: "pending", context: CHECK_CONTEXT, description: "the mesh is mapping this head onto its module graph",
|
|
})
|
|
.catch((err) => console.error(`[gitea] ${key}: could not say a check is pending — ${err instanceof Error ? err.message : err}`));
|
|
}
|
|
heads[key] = String(pull.head_sha);
|
|
changed = true;
|
|
}
|
|
}
|
|
if (!primedPulls || changed) keepHeads();
|
|
primedPulls = true;
|
|
lastPullLook = began;
|
|
}
|
|
|
|
// **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<void> {
|
|
const c = (event.body ?? {}) as Checked;
|
|
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;
|
|
if (c.number) {
|
|
target = await client.getPullRequest(c.owner, c.repo, c.number).then((p) => p.html_url || undefined).catch(() => undefined);
|
|
}
|
|
for (const status of statusesFor(c)) {
|
|
await client.setCommitStatus(c.owner, c.repo, c.commit, target ? { ...status, target_url: target } : status);
|
|
}
|
|
const comment = commentFor(c);
|
|
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}`);
|
|
}
|
|
|
|
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 => {
|
|
// **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) => setVerdict(client, event));
|
|
console.log("[gitea] watching for new repositories and merged pull requests");
|
|
}
|